diff --git a/.github/scripts/determine-runners-tags.sh b/.github/scripts/determine-runners-tags.sh deleted file mode 100755 index 5a15b3a52ad..00000000000 --- a/.github/scripts/determine-runners-tags.sh +++ /dev/null @@ -1,119 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# Script to determine Docker tags for runners images (Alpine and distroless variants) -# -# Usage: determine-runners-tags.sh RELEASE_TYPE N8N_VERSION_TAG GHCR_BASE DOCKER_BASE PLATFORM GITHUB_OUTPUT -# -# Example: -# determine-runners-tags.sh \ -# "stable" \ -# "1.123.0" \ -# "ghcr.io/n8n-io/runners" \ -# "n8nio/runners" \ -# "amd64" \ -# "$GITHUB_OUTPUT" -# -# Output (written to GITHUB_OUTPUT): -# Alpine variant: -# tags=ghcr.io/n8n-io/runners:1.123.0-amd64, n8nio/runners:1.123.0-amd64 -# ghcr_platform_tag=ghcr.io/n8n-io/runners:1.123.0-amd64 -# dockerhub_platform_tag=n8nio/runners:1.123.0-amd64 -# primary_ghcr_manifest_tag=ghcr.io/n8n-io/runners:1.123.0 -# -# Distroless variant: -# tags_distroless=ghcr.io/n8n-io/runners:1.123.0-distroless-amd64, n8nio/runners:1.123.0-distroless-amd64 -# ghcr_platform_tag_distroless=ghcr.io/n8n-io/runners:1.123.0-distroless-amd64 -# dockerhub_platform_tag_distroless=n8nio/runners:1.123.0-distroless-amd64 -# primary_ghcr_manifest_tag_distroless=ghcr.io/n8n-io/runners:1.123.0-distroless - -RELEASE_TYPE="${1:?Missing RELEASE_TYPE argument}" -N8N_VERSION_TAG="${2:?Missing N8N_VERSION_TAG argument}" -GHCR_BASE="${3:?Missing GHCR_BASE argument}" -DOCKER_BASE="${4:?Missing DOCKER_BASE argument}" -PLATFORM="${5:?Missing PLATFORM argument}" -GITHUB_OUTPUT="${6:?Missing GITHUB_OUTPUT argument}" - -generate_tags() { - local VARIANT_SUFFIX="$1" - local OUTPUT_FILE="$2" - - local GHCR_TAGS_FOR_PUSH="" - local DOCKER_TAGS_FOR_PUSH="" - local PRIMARY_GHCR_MANIFEST_TAG_VALUE="" - - case "$RELEASE_TYPE" in - "stable") - PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}${VARIANT_SUFFIX}" - GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}" - DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:${N8N_VERSION_TAG}${VARIANT_SUFFIX}-${PLATFORM}" - ;; - "nightly") - PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:nightly${VARIANT_SUFFIX}" - GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}" - DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:nightly${VARIANT_SUFFIX}-${PLATFORM}" - ;; - "branch") - PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}${VARIANT_SUFFIX}" - GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}" - DOCKER_TAGS_FOR_PUSH="" - ;; - "dev"|*) - if [[ "$N8N_VERSION_TAG" == pr-* ]]; then - PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}${VARIANT_SUFFIX}" - GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}" - DOCKER_TAGS_FOR_PUSH="" - else - PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:dev${VARIANT_SUFFIX}" - GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}" - DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:dev${VARIANT_SUFFIX}-${PLATFORM}" - fi - ;; - esac - - local ALL_TAGS="${GHCR_TAGS_FOR_PUSH}" - if [[ -n "$DOCKER_TAGS_FOR_PUSH" ]]; then - ALL_TAGS="${ALL_TAGS}\n${DOCKER_TAGS_FOR_PUSH}" - fi - - { - echo "tags<> "$OUTPUT_FILE" - - # Only output manifest tags from the first platform to avoid duplicates - if [[ "$PLATFORM" == "amd64" ]]; then - echo "primary_ghcr_manifest_tag=${PRIMARY_GHCR_MANIFEST_TAG_VALUE}" >> "$OUTPUT_FILE" - fi -} - -# Reads outputs from a temp file and appends them to GITHUB_OUTPUT with _distroless suffix -# Transforms variable names: tags -> tags_distroless, ghcr_platform_tag -> ghcr_platform_tag_distroless -transform_and_append_distroless_outputs() { - local TEMP_FILE="$1" - - while IFS= read -r line; do - if [[ "$line" == "tags<> "$GITHUB_OUTPUT" - elif [[ "$line" =~ ^(ghcr_platform_tag|dockerhub_platform_tag|primary_ghcr_manifest_tag)= ]]; then - key="${line%%=*}" - value="${line#*=}" - echo "${key}_distroless=${value}" >> "$GITHUB_OUTPUT" - else - # Pass through EOF markers and tag content - echo "$line" >> "$GITHUB_OUTPUT" - fi - done < "$TEMP_FILE" -} - -# Generate tags for Alpine variant (no suffix) -generate_tags "" "$GITHUB_OUTPUT" - -# Generate tags for distroless variant -DISTROLESS_OUTPUT=$(mktemp) -generate_tags "-distroless" "$DISTROLESS_OUTPUT" -transform_and_append_distroless_outputs "$DISTROLESS_OUTPUT" -rm "$DISTROLESS_OUTPUT" diff --git a/.github/scripts/docker/docker-config.mjs b/.github/scripts/docker/docker-config.mjs new file mode 100644 index 00000000000..5f426421bf4 --- /dev/null +++ b/.github/scripts/docker/docker-config.mjs @@ -0,0 +1,171 @@ +#!/usr/bin/env node + +import { appendFileSync } from 'node:fs'; + +class BuildContext { + constructor() { + this.githubOutput = process.env.GITHUB_OUTPUT || null; + } + + determine({ event, pr, branch, version, releaseType, pushEnabled }) { + let context = { + version: '', + release_type: '', + platforms: ['linux/amd64', 'linux/arm64'], + push_to_ghcr: true, + push_to_docker: false, + }; + + // Determine version and release type based on event + switch (event) { + case 'schedule': + context.version = 'nightly'; + context.release_type = 'nightly'; + context.push_to_docker = true; + break; + + case 'pull_request': + context.version = `pr-${pr}`; + context.release_type = 'dev'; + context.push_to_ghcr = false; + break; + + case 'workflow_dispatch': + context.version = `branch-${this.sanitizeBranch(branch)}`; + context.release_type = 'branch'; + context.platforms = ['linux/amd64']; + break; + + case 'push': + if (branch === 'master') { + context.version = 'dev'; + context.release_type = 'dev'; + context.push_to_docker = true; + } else { + context.version = `branch-${this.sanitizeBranch(branch)}`; + context.release_type = 'branch'; + context.platforms = ['linux/amd64']; + } + break; + + case 'workflow_call': + case 'release': + if (!version) throw new Error('Version required for release'); + context.version = version; + context.release_type = releaseType || 'stable'; + context.push_to_docker = true; + break; + + default: + throw new Error(`Unknown event: ${event}`); + } + + // Handle push_enabled override + if (pushEnabled !== undefined) { + context.push_enabled = pushEnabled; + } else { + context.push_enabled = context.push_to_ghcr; + } + + return context; + } + + sanitizeBranch(branch) { + if (!branch) return 'unknown'; + return branch + .toLowerCase() + .replace(/[^a-z0-9._-]/g, '-') + .replace(/^[.-]/, '') + .replace(/[.-]$/, '') + .substring(0, 128); + } + + buildMatrix(platforms) { + const runners = { + 'linux/amd64': 'blacksmith-4vcpu-ubuntu-2204', + 'linux/arm64': 'blacksmith-4vcpu-ubuntu-2204-arm', + }; + + const matrix = { + platform: [], + include: [], + }; + + for (const platform of platforms) { + const shortName = platform.split('/').pop(); // amd64 or arm64 + matrix.platform.push(shortName); + matrix.include.push({ + platform: shortName, + runner: runners[platform], + docker_platform: platform, + }); + } + + return matrix; + } + + output(context, matrix = null) { + const buildMatrix = matrix || this.buildMatrix(context.platforms); + + if (this.githubOutput) { + const outputs = [ + `version=${context.version}`, + `release_type=${context.release_type}`, + `platforms=${JSON.stringify(context.platforms)}`, + `push_to_ghcr=${context.push_to_ghcr}`, + `push_to_docker=${context.push_to_docker}`, + `push_enabled=${context.push_enabled}`, + `build_matrix=${JSON.stringify(buildMatrix)}`, + ]; + appendFileSync(this.githubOutput, outputs.join('\n') + '\n'); + } else { + console.log(JSON.stringify({ ...context, build_matrix: buildMatrix }, null, 2)); + } + } +} + +// CLI - Simple argument parsing +if (import.meta.url === `file://${process.argv[1]}`) { + const args = process.argv.slice(2); + const getArg = (name) => { + const index = args.indexOf(`--${name}`); + if (index === -1 || !args[index + 1]) return undefined; + const value = args[index + 1]; + // Handle empty strings and 'null' as undefined + return value === '' || value === 'null' ? undefined : value; + }; + + try { + const context = new BuildContext(); + const pushEnabledArg = getArg('push-enabled'); + const result = context.determine({ + event: getArg('event') || process.env.GITHUB_EVENT_NAME, + pr: getArg('pr') || process.env.GITHUB_PR_NUMBER, + branch: getArg('branch') || process.env.GITHUB_REF_NAME, + version: getArg('version'), + releaseType: getArg('release-type'), + pushEnabled: pushEnabledArg === 'true' ? true : pushEnabledArg === 'false' ? false : undefined, + }); + + const matrix = context.buildMatrix(result.platforms); + + // Debug output when GITHUB_OUTPUT is set (running in Actions) + if (context.githubOutput) { + console.log('=== Build Context ==='); + console.log(`version: ${result.version}`); + console.log(`release_type: ${result.release_type}`); + console.log(`platforms: ${JSON.stringify(result.platforms, null, 2)}`); + console.log(`push_to_ghcr: ${result.push_to_ghcr}`); + console.log(`push_to_docker: ${result.push_to_docker}`); + console.log(`push_enabled: ${result.push_enabled}`); + console.log('build_matrix:', JSON.stringify(matrix, null, 2)); + } + + context.output(result, matrix); + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } +} + +export default BuildContext; \ No newline at end of file diff --git a/.github/scripts/docker/docker-tags.mjs b/.github/scripts/docker/docker-tags.mjs new file mode 100644 index 00000000000..8f1f7dac631 --- /dev/null +++ b/.github/scripts/docker/docker-tags.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node + +import { appendFileSync } from 'node:fs'; + +class TagGenerator { + constructor() { + this.githubOwner = process.env.GITHUB_REPOSITORY_OWNER || 'n8n-io'; + this.dockerUsername = process.env.DOCKER_USERNAME || 'n8nio'; + this.githubOutput = process.env.GITHUB_OUTPUT || null; + } + + generate({ image, version, platform, includeDockerHub = false }) { + let imageName = image; + let versionSuffix = ''; + + if (image === 'runners-distroless') { + imageName = 'runners'; + versionSuffix = '-distroless'; + } + + const platformSuffix = platform ? `-${platform.split('/').pop()}` : ''; + const fullVersion = `${version}${versionSuffix}${platformSuffix}`; + + const tags = { + ghcr: [`ghcr.io/${this.githubOwner}/${imageName}:${fullVersion}`], + docker: includeDockerHub ? [`${this.dockerUsername}/${imageName}:${fullVersion}`] : [], + }; + + tags.all = [...tags.ghcr, ...tags.docker]; + return tags; + } + + output(tags, prefix = '') { + if (this.githubOutput) { + const prefixStr = prefix ? `${prefix}_` : ''; + const primaryTag = tags.ghcr[0] ? tags.ghcr[0].replace(/-amd64$|-arm64$/, '') : ''; + const outputs = [ + `${prefixStr}tags=${tags.all.join(',')}`, + `${prefixStr}ghcr_tag=${tags.ghcr[0] || ''}`, + `${prefixStr}docker_tag=${tags.docker[0] || ''}`, + `${prefixStr}primary_tag=${primaryTag}`, + ]; + appendFileSync(this.githubOutput, outputs.join('\n') + '\n'); + } else { + console.log(JSON.stringify(tags, null, 2)); + } + } + + generateAll({ version, platform, includeDockerHub = false }) { + const images = ['n8n', 'runners', 'runners-distroless']; + const results = {}; + + for (const image of images) { + const tags = this.generate({ image, version, platform, includeDockerHub }); + const prefix = image.replace('-distroless', '_distroless'); + results[prefix] = tags; + + if (this.githubOutput) { + this.output(tags, prefix); + } + } + + return results; + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const args = process.argv.slice(2); + const getArg = (name) => { + const index = args.indexOf(`--${name}`); + return index !== -1 && args[index + 1] ? args[index + 1] : undefined; + }; + const hasFlag = (name) => args.includes(`--${name}`); + + try { + const generator = new TagGenerator(); + const version = getArg('version'); + + if (!version) { + console.error('Error: --version is required'); + process.exit(1); + } + + if (hasFlag('all')) { + const results = generator.generateAll({ + version, + platform: getArg('platform'), + includeDockerHub: hasFlag('include-docker'), + }); + if (!generator.githubOutput) { + console.log(JSON.stringify(results, null, 2)); + } + } else { + const image = getArg('image'); + if (!image) { + console.error('Error: Either --image or --all is required'); + process.exit(1); + } + const tags = generator.generate({ + image, + version, + platform: getArg('platform'), + includeDockerHub: hasFlag('include-docker'), + }); + generator.output(tags); + } + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } +} + +export default TagGenerator; diff --git a/.github/workflows/ci-postgres-mysql.yml b/.github/workflows/ci-postgres-mysql.yml index 2620a015dc3..7cd621b653b 100644 --- a/.github/workflows/ci-postgres-mysql.yml +++ b/.github/workflows/ci-postgres-mysql.yml @@ -59,7 +59,7 @@ jobs: name: MariaDB needs: build runs-on: blacksmith-4vcpu-ubuntu-2204 - timeout-minutes: 20 + timeout-minutes: 30 env: DB_MYSQLDB_PASSWORD: password DB_MYSQLDB_POOL_SIZE: 1 diff --git a/.github/workflows/ci-pull-requests.yml b/.github/workflows/ci-pull-requests.yml index db2bdc7aa1c..269bdce4ba5 100644 --- a/.github/workflows/ci-pull-requests.yml +++ b/.github/workflows/ci-pull-requests.yml @@ -6,8 +6,12 @@ on: - '**' - '!release/*' +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + env: - COVERAGE_ENABLED: 'true' # Set globally for all jobs - ensures Turbo cache consistency + COVERAGE_ENABLED: 'true' # Set globally for all jobs - ensures Turbo cache consistency jobs: install-and-build: @@ -46,10 +50,6 @@ jobs: if: steps.paths-filter.outputs.non-python == 'true' run: pnpm format:check - - name: Run typecheck - if: steps.paths-filter.outputs.non-python == 'true' - run: pnpm typecheck - - name: Upload Frontend Build Artifacts if: steps.paths-filter.outputs.frontend == 'true' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 @@ -100,6 +100,21 @@ jobs: secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + typecheck: + name: Typecheck + if: needs.install-and-build.outputs.non_python_changed == 'true' + runs-on: blacksmith-4vcpu-ubuntu-2204 + needs: install-and-build + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: refs/pull/${{ github.event.pull_request.number }}/merge + + - name: Setup Node.js + uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1 + with: + build-command: pnpm typecheck + lint: name: Lint if: needs.install-and-build.outputs.non_python_changed == 'true' @@ -107,3 +122,25 @@ jobs: needs: install-and-build with: ref: refs/pull/${{ github.event.pull_request.number }}/merge + + e2e-test: + name: E2E Tests + needs: [install-and-build, unit-test, typecheck, lint] + if: | + always() && + needs.install-and-build.result == 'success' && + needs.unit-test.result != 'failure' && + needs.typecheck.result != 'failure' && + needs.lint.result != 'failure' + uses: ./.github/workflows/playwright-test-reusable.yml + secrets: inherit + + e2e-checks: + name: E2E - Checks + runs-on: ubuntu-latest + needs: [e2e-test] + if: always() + steps: + - name: Fail if E2E tests failed + if: needs.e2e-test.result == 'failure' + run: exit 1 diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml index 6695b1ff5ba..9ef2690077f 100644 --- a/.github/workflows/docker-build-push.yml +++ b/.github/workflows/docker-build-push.yml @@ -1,9 +1,7 @@ # This workflow is used to build and push the Docker image for n8nio/n8n and n8nio/runners -# - determine-build-context: Determines what needs to be built based on the trigger -# - build-and-push-docker: This builds on both an ARM64 and AMD64 runner so the builds are native to the platform. Uses blacksmith native runners and build-push-action -# - create_multi_arch_manifest: This creates the multi-arch manifest for the Docker image. Needed to recombine the images from the build-and-push-docker job since they are separate runners. -# - security-scan: This scans the n8nio/n8n Docker image for security vulnerabilities using Trivy. -# - security-scan-runners: This scans the n8nio/runners Docker image for security vulnerabilities using Trivy. +# +# - Uses docker-config.mjs for context determination, this determines what needs to be built based on the trigger +# - Uses docker-tags.mjs for tag generation, this generates the tags for the images name: 'Docker: Build and Push' @@ -50,6 +48,8 @@ on: - ready_for_review paths: - '.github/workflows/docker-build-push.yml' + - '.github/scripts/docker/docker-config.mjs' + - '.github/scripts/docker/docker-tags.mjs' - 'docker/images/n8n/Dockerfile' - 'docker/images/runners/Dockerfile' - 'docker/images/runners/Dockerfile.distroless' @@ -60,107 +60,24 @@ jobs: runs-on: ubuntu-latest outputs: release_type: ${{ steps.context.outputs.release_type }} - n8n_version: ${{ steps.context.outputs.n8n_version }} + n8n_version: ${{ steps.context.outputs.version }} push_enabled: ${{ steps.context.outputs.push_enabled }} - build_matrix: ${{ steps.matrix.outputs.matrix }} + push_to_docker: ${{ steps.context.outputs.push_to_docker }} + build_matrix: ${{ steps.context.outputs.build_matrix }} steps: - - name: Determine build context values + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Determine build context id: context run: | - # Debug info - echo "Event: ${{ github.event_name }}" - echo "Ref: ${{ github.ref }}" - echo "Ref Name: ${{ github.ref_name }}" - - # Check if called by another workflow (has n8n_version input) - if [[ -n "${{ inputs.n8n_version }}" ]]; then - # workflow_call - used for releases - { - echo "release_type=${{ inputs.release_type }}" - echo "n8n_version=${{ inputs.n8n_version }}" - echo "push_enabled=${{ inputs.push_enabled }}" - } >> "$GITHUB_OUTPUT" - - elif [[ "${{ github.event_name }}" == "schedule" ]]; then - # Nightly builds - { - echo "release_type=nightly" - echo "n8n_version=snapshot" - echo "push_enabled=true" - } >> "$GITHUB_OUTPUT" - - elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - # Build branches for Nathan deploy - BRANCH_NAME="${{ github.ref_name }}" - - # Fallback to parsing ref if ref_name is empty - if [[ -z "$BRANCH_NAME" ]] && [[ "${{ github.ref }}" =~ ^refs/heads/(.+)$ ]]; then - BRANCH_NAME="${BASH_REMATCH[1]}" - fi - - # Sanitize branch name for Docker tag - SAFE_BRANCH_NAME=$(echo "$BRANCH_NAME" | tr '/' '-' | tr -cd '[:alnum:]-_') - - if [[ -z "$SAFE_BRANCH_NAME" ]]; then - echo "Error: Could not determine valid branch name" - exit 1 - fi - - { - echo "release_type=branch" - echo "n8n_version=branch-${SAFE_BRANCH_NAME}" - echo "push_enabled=${{ inputs.push_enabled }}" - } >> "$GITHUB_OUTPUT" - - elif [[ "${{ github.event_name }}" == "pull_request" ]]; then - # Direct PR triggers for testing Dockerfile changes - { - echo "release_type=dev" - echo "n8n_version=pr-${{ github.event.pull_request.number }}" - echo "push_enabled=false" - } >> "$GITHUB_OUTPUT" - fi - - # Output summary for logs - echo "=== Build Context Summary ===" - echo "Release type: $(grep release_type "$GITHUB_OUTPUT" | cut -d= -f2)" - echo "N8N version: $(grep n8n_version "$GITHUB_OUTPUT" | cut -d= -f2)" - echo "Push enabled: $(grep push_enabled "$GITHUB_OUTPUT" | cut -d= -f2)" - - - name: Determine build matrix - id: matrix - run: | - RELEASE_TYPE="${{ steps.context.outputs.release_type }}" - - # Branch builds only need AMD64, everything else needs both platforms - if [[ "$RELEASE_TYPE" == "branch" ]]; then - MATRIX='{ - "platform": ["amd64"], - "include": [{ - "platform": "amd64", - "runner": "blacksmith-4vcpu-ubuntu-2204", - "docker_platform": "linux/amd64" - }] - }' - else - # All other builds (stable, nightly, dev, PR) need both platforms - MATRIX='{ - "platform": ["amd64", "arm64"], - "include": [{ - "platform": "amd64", - "runner": "blacksmith-4vcpu-ubuntu-2204", - "docker_platform": "linux/amd64" - }, { - "platform": "arm64", - "runner": "blacksmith-4vcpu-ubuntu-2204-arm", - "docker_platform": "linux/arm64" - }] - }' - fi - - # Output matrix as single line for GITHUB_OUTPUT - echo "matrix=$(echo "$MATRIX" | jq -c .)" >> "$GITHUB_OUTPUT" - echo "Build matrix: $(echo "$MATRIX" | jq .)" + node .github/scripts/docker/docker-config.mjs \ + --event "${{ github.event_name }}" \ + --pr "${{ github.event.pull_request.number }}" \ + --branch "${{ github.ref_name }}" \ + --version "${{ inputs.n8n_version }}" \ + --release-type "${{ inputs.release_type }}" \ + --push-enabled "${{ inputs.push_enabled }}" build-and-push-docker: name: Build App, then Build and Push Docker Image (${{ matrix.platform }}) @@ -170,10 +87,10 @@ jobs: strategy: matrix: ${{ fromJSON(needs.determine-build-context.outputs.build_matrix) }} outputs: - image_ref: ${{ steps.determine-tags.outputs.primary_ghcr_manifest_tag }} - primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.primary_ghcr_manifest_tag }} - runners_primary_ghcr_manifest_tag: ${{ steps.determine-runners-tags.outputs.primary_ghcr_manifest_tag }} - runners_distroless_primary_ghcr_manifest_tag: ${{ steps.determine-runners-tags.outputs.primary_ghcr_manifest_tag_distroless }} + image_ref: ${{ steps.determine-tags.outputs.n8n_primary_tag }} + primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.n8n_primary_tag }} + runners_primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.runners_primary_tag }} + runners_distroless_primary_ghcr_manifest_tag: ${{ steps.determine-tags.outputs.runners_distroless_primary_tag }} steps: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -181,101 +98,23 @@ jobs: fetch-depth: 0 - name: Setup and Build - uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1 + uses: ./.github/actions/setup-nodejs-blacksmith with: build-command: pnpm build:n8n - - name: Determine Docker tags + - name: Determine Docker tags for all images id: determine-tags run: | - RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}" - N8N_VERSION_TAG="${{ needs.determine-build-context.outputs.n8n_version }}" - GHCR_BASE="ghcr.io/${{ github.repository_owner }}/n8n" - DOCKER_BASE="${{ secrets.DOCKER_USERNAME }}/n8n" - PLATFORM="${{ matrix.platform }}" + node .github/scripts/docker/docker-tags.mjs \ + --all \ + --version "${{ needs.determine-build-context.outputs.n8n_version }}" \ + --platform "${{ matrix.docker_platform }}" \ + ${{ needs.determine-build-context.outputs.push_to_docker == 'true' && '--include-docker' || '' }} - GHCR_TAGS_FOR_PUSH="" - DOCKER_TAGS_FOR_PUSH="" - - PRIMARY_GHCR_MANIFEST_TAG_VALUE="" - - # Validate inputs - if [[ "$RELEASE_TYPE" == "stable" && -z "$N8N_VERSION_TAG" ]]; then - echo "Error: N8N_VERSION_TAG is empty for a stable release." - exit 1 - fi - - if [[ "$RELEASE_TYPE" == "branch" && -z "$N8N_VERSION_TAG" ]]; then - echo "Error: N8N_VERSION_TAG is empty for a branch release." - exit 1 - fi - - # Determine tags based on release type - case "$RELEASE_TYPE" in - "stable") - PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}" - GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}" - DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:${N8N_VERSION_TAG}-${PLATFORM}" - ;; - "nightly") - PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:nightly" - GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}" - DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:nightly-${PLATFORM}" - ;; - "branch") - PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}" - GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}" - # No Docker Hub tags for branch builds - DOCKER_TAGS_FOR_PUSH="" - ;; - "dev"|*) - if [[ "$N8N_VERSION_TAG" == pr-* ]]; then - # PR builds only go to GHCR - PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:${N8N_VERSION_TAG}" - GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}" - DOCKER_TAGS_FOR_PUSH="" - else - # Regular dev builds go to both registries - PRIMARY_GHCR_MANIFEST_TAG_VALUE="${GHCR_BASE}:dev" - GHCR_TAGS_FOR_PUSH="${PRIMARY_GHCR_MANIFEST_TAG_VALUE}-${PLATFORM}" - DOCKER_TAGS_FOR_PUSH="${DOCKER_BASE}:dev-${PLATFORM}" - fi - ;; - esac - - # Combine all tags - ALL_TAGS="${GHCR_TAGS_FOR_PUSH}" - if [[ -n "$DOCKER_TAGS_FOR_PUSH" ]]; then - ALL_TAGS="${ALL_TAGS}\n${DOCKER_TAGS_FOR_PUSH}" - fi - - echo "Generated Tags for push: $ALL_TAGS" - { - echo "tags<> "$GITHUB_OUTPUT" - - { - echo "ghcr_platform_tag=${GHCR_TAGS_FOR_PUSH}" - echo "dockerhub_platform_tag=${DOCKER_TAGS_FOR_PUSH}" - } >> "$GITHUB_OUTPUT" - - # Only output manifest tags from the first platform to avoid duplicates - if [[ "$PLATFORM" == "amd64" ]]; then - echo "primary_ghcr_manifest_tag=${PRIMARY_GHCR_MANIFEST_TAG_VALUE}" >> "$GITHUB_OUTPUT" - fi - - - name: Determine Docker tags (runners variants) - id: determine-runners-tags - run: | - .github/scripts/determine-runners-tags.sh \ - "${{ needs.determine-build-context.outputs.release_type }}" \ - "${{ needs.determine-build-context.outputs.n8n_version }}" \ - "ghcr.io/${{ github.repository_owner }}/runners" \ - "${{ secrets.DOCKER_USERNAME }}/runners" \ - "${{ matrix.platform }}" \ - "$GITHUB_OUTPUT" + echo "=== Generated Docker Tags ===" + cat "$GITHUB_OUTPUT" | grep "_tags=" | while IFS='=' read -r key value; do + echo "${key}: ${value%%,*}..." # Show first tag for brevity + done - name: Set up Docker Buildx uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 @@ -289,10 +128,9 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub - if: needs.determine-build-context.outputs.push_enabled == 'true' && ( - steps.determine-tags.outputs.dockerhub_platform_tag != '' || - steps.determine-runners-tags.outputs.dockerhub_platform_tag != '' || - steps.determine-runners-tags.outputs.dockerhub_platform_tag_distroless != '') + if: | + needs.determine-build-context.outputs.push_enabled == 'true' && + needs.determine-build-context.outputs.push_to_docker == 'true' uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 with: username: ${{ secrets.DOCKER_USERNAME }} @@ -311,7 +149,7 @@ jobs: provenance: true sbom: true push: ${{ needs.determine-build-context.outputs.push_enabled == 'true' }} - tags: ${{ steps.determine-tags.outputs.tags }} + tags: ${{ steps.determine-tags.outputs.n8n_tags }} - name: Build and push task runners Docker image (Alpine) uses: useblacksmith/build-push-action@574eb0ee0b59c6a687ace24192f0727dfb65d6d7 # v1.2 @@ -326,7 +164,7 @@ jobs: provenance: true sbom: true push: ${{ needs.determine-build-context.outputs.push_enabled == 'true' }} - tags: ${{ steps.determine-runners-tags.outputs.tags }} + tags: ${{ steps.determine-tags.outputs.runners_tags }} - name: Build and push task runners Docker image (distroless) uses: useblacksmith/build-push-action@574eb0ee0b59c6a687ace24192f0727dfb65d6d7 # v1.2 @@ -341,7 +179,7 @@ jobs: provenance: true sbom: true push: ${{ needs.determine-build-context.outputs.push_enabled == 'true' }} - tags: ${{ steps.determine-runners-tags.outputs.tags_distroless }} + tags: ${{ steps.determine-tags.outputs.runners_distroless_tags }} create_multi_arch_manifest: name: Create Multi-Arch Manifest @@ -361,181 +199,70 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Determine Docker Hub manifest tag - id: dockerhub_check - run: | - RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}" - N8N_VERSION="${{ needs.determine-build-context.outputs.n8n_version }}" - DOCKER_BASE="${{ secrets.DOCKER_USERNAME }}/n8n" - - # Determine if Docker Hub manifest is needed and construct the tag - case "$RELEASE_TYPE" in - "stable") - { - echo "DOCKER_MANIFEST_TAG=${DOCKER_BASE}:${N8N_VERSION}" - echo "CREATE_DOCKERHUB_MANIFEST=true" - } >> "$GITHUB_OUTPUT" - ;; - "nightly") - { - echo "DOCKER_MANIFEST_TAG=${DOCKER_BASE}:nightly" - echo "CREATE_DOCKERHUB_MANIFEST=true" - } >> "$GITHUB_OUTPUT" - ;; - "dev") - if [[ "$N8N_VERSION" != pr-* ]]; then - { - echo "DOCKER_MANIFEST_TAG=${DOCKER_BASE}:dev" - echo "CREATE_DOCKERHUB_MANIFEST=true" - } >> "$GITHUB_OUTPUT" - else - echo "CREATE_DOCKERHUB_MANIFEST=false" >> "$GITHUB_OUTPUT" - fi - ;; - *) - echo "CREATE_DOCKERHUB_MANIFEST=false" >> "$GITHUB_OUTPUT" - ;; - esac - - - name: Determine Docker Hub manifest tags (runners variants) - id: dockerhub_runners_check - run: | - RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}" - N8N_VERSION="${{ needs.determine-build-context.outputs.n8n_version }}" - DOCKER_BASE="${{ secrets.DOCKER_USERNAME }}/runners" - - # Generate manifest tags for both Alpine and distroless variants - for VARIANT in "" "_distroless"; do - SUFFIX="${VARIANT//_/-}" # Convert _distroless to -distroless for tag - OUTPUT_SUFFIX="${VARIANT}" # Keep underscore for output var names - - case "$RELEASE_TYPE" in - "stable") - echo "DOCKER_MANIFEST_TAG${OUTPUT_SUFFIX}=${DOCKER_BASE}:${N8N_VERSION}${SUFFIX}" >> "$GITHUB_OUTPUT" - echo "CREATE_DOCKERHUB_MANIFEST${OUTPUT_SUFFIX}=true" >> "$GITHUB_OUTPUT" - ;; - "nightly") - echo "DOCKER_MANIFEST_TAG${OUTPUT_SUFFIX}=${DOCKER_BASE}:nightly${SUFFIX}" >> "$GITHUB_OUTPUT" - echo "CREATE_DOCKERHUB_MANIFEST${OUTPUT_SUFFIX}=true" >> "$GITHUB_OUTPUT" - ;; - "dev") - if [[ "$N8N_VERSION" != pr-* ]]; then - echo "DOCKER_MANIFEST_TAG${OUTPUT_SUFFIX}=${DOCKER_BASE}:dev${SUFFIX}" >> "$GITHUB_OUTPUT" - echo "CREATE_DOCKERHUB_MANIFEST${OUTPUT_SUFFIX}=true" >> "$GITHUB_OUTPUT" - else - echo "CREATE_DOCKERHUB_MANIFEST${OUTPUT_SUFFIX}=false" >> "$GITHUB_OUTPUT" - fi - ;; - *) - echo "CREATE_DOCKERHUB_MANIFEST${OUTPUT_SUFFIX}=false" >> "$GITHUB_OUTPUT" - ;; - esac - done - - name: Login to Docker Hub - if: steps.dockerhub_check.outputs.CREATE_DOCKERHUB_MANIFEST == 'true' || - steps.dockerhub_runners_check.outputs.CREATE_DOCKERHUB_MANIFEST == 'true' || - steps.dockerhub_runners_check.outputs.CREATE_DOCKERHUB_MANIFEST_distroless == 'true' + if: needs.determine-build-context.outputs.push_to_docker == 'true' uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - name: Create GHCR multi-arch manifest - if: needs.build-and-push-docker.outputs.primary_ghcr_manifest_tag != '' + - name: Create GHCR multi-arch manifests run: | - MANIFEST_TAG="${{ needs.build-and-push-docker.outputs.primary_ghcr_manifest_tag }}" RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}" - echo "Creating GHCR manifest: $MANIFEST_TAG" + # Function to create manifest for an image + create_manifest() { + local IMAGE_NAME=$1 + local MANIFEST_TAG=$2 - # For branch builds, only AMD64 is built - if [[ "$RELEASE_TYPE" == "branch" ]]; then - docker buildx imagetools create \ - --tag $MANIFEST_TAG \ - ${MANIFEST_TAG}-amd64 - else - docker buildx imagetools create \ - --tag $MANIFEST_TAG \ - ${MANIFEST_TAG}-amd64 \ - ${MANIFEST_TAG}-arm64 - fi + if [[ -z "$MANIFEST_TAG" ]]; then + echo "Skipping $IMAGE_NAME - no manifest tag" + return + fi - - name: Create GHCR multi-arch manifest for runners (Alpine) - if: needs.build-and-push-docker.outputs.runners_primary_ghcr_manifest_tag != '' + echo "Creating GHCR manifest for $IMAGE_NAME: $MANIFEST_TAG" + + # For branch builds, only AMD64 is built + if [[ "$RELEASE_TYPE" == "branch" ]]; then + docker buildx imagetools create \ + --tag "$MANIFEST_TAG" \ + "${MANIFEST_TAG}-amd64" + else + docker buildx imagetools create \ + --tag "$MANIFEST_TAG" \ + "${MANIFEST_TAG}-amd64" \ + "${MANIFEST_TAG}-arm64" + fi + } + + # Create manifests for all images + create_manifest "n8n" "${{ needs.build-and-push-docker.outputs.primary_ghcr_manifest_tag }}" + create_manifest "runners" "${{ needs.build-and-push-docker.outputs.runners_primary_ghcr_manifest_tag }}" + create_manifest "runners-distroless" "${{ needs.build-and-push-docker.outputs.runners_distroless_primary_ghcr_manifest_tag }}" + + - name: Create Docker Hub manifests + if: needs.determine-build-context.outputs.push_to_docker == 'true' run: | - MANIFEST_TAG="${{ needs.build-and-push-docker.outputs.runners_primary_ghcr_manifest_tag }}" - RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}" + VERSION="${{ needs.determine-build-context.outputs.n8n_version }}" + DOCKER_BASE="${{ secrets.DOCKER_USERNAME }}" - echo "Creating GHCR runners manifest: $MANIFEST_TAG" + # Create manifests for each image type + declare -A images=( + ["n8n"]="${VERSION}" + ["runners"]="${VERSION}" + ["runners-distroless"]="${VERSION}-distroless" + ) - # For branch builds, only AMD64 is built - if [[ "$RELEASE_TYPE" == "branch" ]]; then + for image in "${!images[@]}"; do + TAG_SUFFIX="${images[$image]}" + IMAGE_NAME="${image//-distroless/}" # Remove -distroless from image name + + echo "Creating Docker Hub manifest for $image" docker buildx imagetools create \ - --tag $MANIFEST_TAG \ - ${MANIFEST_TAG}-amd64 - else - docker buildx imagetools create \ - --tag $MANIFEST_TAG \ - ${MANIFEST_TAG}-amd64 \ - ${MANIFEST_TAG}-arm64 - fi - - - name: Create GHCR multi-arch manifest for runners (distroless) - if: needs.build-and-push-docker.outputs.runners_distroless_primary_ghcr_manifest_tag != '' - run: | - MANIFEST_TAG="${{ needs.build-and-push-docker.outputs.runners_distroless_primary_ghcr_manifest_tag }}" - RELEASE_TYPE="${{ needs.determine-build-context.outputs.release_type }}" - - echo "Creating GHCR runners distroless manifest: $MANIFEST_TAG" - - # For branch builds, only AMD64 is built - if [[ "$RELEASE_TYPE" == "branch" ]]; then - docker buildx imagetools create \ - --tag $MANIFEST_TAG \ - ${MANIFEST_TAG}-amd64 - else - docker buildx imagetools create \ - --tag $MANIFEST_TAG \ - ${MANIFEST_TAG}-amd64 \ - ${MANIFEST_TAG}-arm64 - fi - - - name: Create Docker Hub multi-arch manifest - if: steps.dockerhub_check.outputs.CREATE_DOCKERHUB_MANIFEST == 'true' - run: | - MANIFEST_TAG="${{ steps.dockerhub_check.outputs.DOCKER_MANIFEST_TAG }}" - - echo "Creating Docker Hub manifest: $MANIFEST_TAG" - - docker buildx imagetools create \ - --tag $MANIFEST_TAG \ - ${MANIFEST_TAG}-amd64 \ - ${MANIFEST_TAG}-arm64 - - - name: Create Docker Hub multi-arch manifest for runners (Alpine) - if: steps.dockerhub_runners_check.outputs.CREATE_DOCKERHUB_MANIFEST == 'true' - run: | - MANIFEST_TAG="${{ steps.dockerhub_runners_check.outputs.DOCKER_MANIFEST_TAG }}" - - echo "Creating Docker Hub manifest: $MANIFEST_TAG" - - docker buildx imagetools create \ - --tag $MANIFEST_TAG \ - ${MANIFEST_TAG}-amd64 \ - ${MANIFEST_TAG}-arm64 - - - name: Create Docker Hub multi-arch manifest for runners (distroless) - if: steps.dockerhub_runners_check.outputs.CREATE_DOCKERHUB_MANIFEST_distroless == 'true' - run: | - MANIFEST_TAG="${{ steps.dockerhub_runners_check.outputs.DOCKER_MANIFEST_TAG_distroless }}" - - echo "Creating Docker Hub distroless manifest: $MANIFEST_TAG" - - docker buildx imagetools create \ - --tag $MANIFEST_TAG \ - ${MANIFEST_TAG}-amd64 \ - ${MANIFEST_TAG}-arm64 + --tag "${DOCKER_BASE}/${IMAGE_NAME}:${TAG_SUFFIX}" \ + "${DOCKER_BASE}/${IMAGE_NAME}:${TAG_SUFFIX}-amd64" \ + "${DOCKER_BASE}/${IMAGE_NAME}:${TAG_SUFFIX}-arm64" + done call-success-url: name: Call Success URL @@ -566,7 +293,7 @@ jobs: security-scan-runners: name: Security Scan (runners) - needs: [determine-build-context, build-and-push-docker] + needs: [determine-build-context, build-and-push-docker, create_multi_arch_manifest] if: | success() && (needs.determine-build-context.outputs.release_type == 'stable' || diff --git a/.github/workflows/e2e-tests-pr-comment.yml b/.github/workflows/e2e-tests-pr-comment.yml deleted file mode 100644 index 6a428eb4016..00000000000 --- a/.github/workflows/e2e-tests-pr-comment.yml +++ /dev/null @@ -1,108 +0,0 @@ -# Manually trigger E2E tests by commenting `/test-e2e` on a PR. -# Dispatches the official "PR E2E" workflow to create the required "E2E - Checks" status. - -name: E2E Tests on PR Comment - -on: - issue_comment: - types: [created] - -permissions: - pull-requests: read - contents: read - actions: write - -jobs: - validate_and_prepare: - name: Validate User and Get PR Details - if: github.event.issue.pull_request && startsWith(github.event.comment.body, '/test-e2e') - runs-on: ubuntu-latest - outputs: - permission_granted: ${{ steps.check_permissions.outputs.permission_granted }} - head_sha: ${{ steps.check_permissions.outputs.head_sha }} - - steps: - - name: Validate User and Get PR Details - id: check_permissions - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const commenter = context.actor; - const issueOwner = context.repo.owner; - const issueRepo = context.repo.repo; - const commentId = context.payload.comment.id; - const prNumber = context.issue.number; - - // Function to add a reaction to the comment - async function addReaction(content) { - try { - await github.rest.reactions.createForIssueComment({ - owner: issueOwner, - repo: issueRepo, - comment_id: commentId, - content: content - }); - } catch (reactionError) { - console.log(`Failed to add reaction '${content}': ${reactionError.message}`); - } - } - - // Initialize outputs - core.setOutput('permission_granted', 'false'); - core.setOutput('head_sha', ''); - - // 1. Check user permissions - try { - const { data: permissions } = await github.rest.repos.getCollaboratorPermissionLevel({ - owner: issueOwner, - repo: issueRepo, - username: commenter - }); - - const allowedPermissions = ['admin', 'write', 'maintain']; - if (!allowedPermissions.includes(permissions.permission)) { - console.log(`User @${commenter} has '${permissions.permission}' permission. Needs 'admin', 'write', or 'maintain'.`); - await addReaction('-1'); - return; - } - console.log(`User @${commenter} has '${permissions.permission}' permission.`); - } catch (error) { - console.log(`Could not verify permissions for @${commenter}: ${error.message}`); - await addReaction('confused'); - return; - } - - // 2. Fetch PR details - try { - const { data: pr } = await github.rest.pulls.get({ - owner: issueOwner, - repo: issueRepo, - pull_number: prNumber, - }); - const headSha = pr.head.sha; - console.log(`Fetched PR details: SHA - ${headSha}, PR Number - ${prNumber}`); - - // Set outputs for next job - core.setOutput('permission_granted', 'true'); - core.setOutput('head_sha', headSha); - await addReaction('+1'); - } catch (error) { - console.log(`Failed to fetch PR details for PR #${prNumber}: ${error.message}`); - await addReaction('confused'); - } - - dispatch_workflow: - name: Dispatch E2E Workflow - needs: validate_and_prepare - if: needs.validate_and_prepare.outputs.permission_granted == 'true' - runs-on: ubuntu-latest - - steps: - - name: Trigger E2E Workflow - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh workflow run e2e-tests-pr.yml \ - --ref ${{ needs.validate_and_prepare.outputs.head_sha }} \ - --repo ${{ github.repository }} diff --git a/.github/workflows/e2e-tests-pr.yml b/.github/workflows/e2e-tests-pr.yml deleted file mode 100644 index e8d1ff95dff..00000000000 --- a/.github/workflows/e2e-tests-pr.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: PR E2E - -on: - pull_request_review: - types: [submitted] - workflow_dispatch: - -concurrency: - group: e2e-${{ github.event.pull_request.number || github.ref }}-${{github.event.review.state}} - cancel-in-progress: true - -jobs: - eligibility_check: - name: Check Eligibility for Test Run - if: github.event_name == 'pull_request_review' && github.event.review.state == 'approved' - uses: ./.github/workflows/check-run-eligibility.yml - with: - is_pr_approved_by_maintainer: true - - run-playwright-tests: - name: Playwright - uses: ./.github/workflows/playwright-test-reusable.yml - needs: [eligibility_check] - # Run for approved PRs or manual triggers - if: | - always() && - ((github.event_name == 'pull_request_review' && needs.eligibility_check.outputs.should_run == 'true') || - (github.event_name == 'workflow_dispatch')) - secrets: inherit - - post-e2e-tests: - name: E2E - Checks - runs-on: ubuntu-latest - needs: [eligibility_check, run-playwright-tests] - if: | - always() && - ((github.event_name == 'pull_request_review' && needs.eligibility_check.result != 'skipped') || - (github.event_name == 'workflow_dispatch')) - steps: - - name: Fail if tests failed - if: needs.run-playwright-tests.result == 'failure' - run: exit 1 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml deleted file mode 100644 index 674ba933c4a..00000000000 --- a/.github/workflows/e2e-tests.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: End-to-End tests -run-name: E2E Tests ${{ inputs.branch }} - ${{ inputs.user }} - -on: - schedule: - - cron: '0 3 * * *' - workflow_dispatch: - inputs: - branch: - description: 'GitHub branch to test.' - required: false - default: 'master' - user: - description: 'User who kicked this off.' - required: false - default: 'schedule' - start-url: - description: 'URL to call after workflow is kicked off.' - required: false - default: '' - success-url: - description: 'URL to call after workflow is done.' - required: false - default: '' - -jobs: - calls-start-url: - name: Calls start URL - runs-on: ubuntu-latest - if: ${{ github.event.inputs.start-url != '' }} - steps: - - name: Calls start URL - env: - START_URL: ${{ github.event.inputs.start-url }} - run: | - [[ "${{ env.START_URL }}" != "" ]] && curl -v -X POST -d 'url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' "${{ env.START_URL }}" || echo "" - shell: bash - - run-playwright-tests: - name: Playwright - uses: ./.github/workflows/playwright-test-reusable.yml - with: - branch: ${{ github.event.inputs.branch || 'master' }} - secrets: inherit - - calls-success-url-notify: - name: Calls success URL and notifies - runs-on: ubuntu-latest - needs: [run-playwright-tests] - if: ${{ github.event.inputs.success-url != '' }} - steps: - - name: Notify Slack on failure - uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0 - if: failure() - with: - status: ${{ job.status }} - channel: '#alerts-build' - webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }} - message: E2E failure for branch `${{ inputs.branch || 'master' }}` deployed by ${{ inputs.user || 'schedule' }} (${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) - - - name: Call Success URL - optionally - env: - SUCCESS_URL: ${{ github.event.inputs.success-url }} - run: | - [[ "${{ env.SUCCESS_URL }}" != "" ]] && curl -v "${{ env.SUCCESS_URL }}" || echo "" - shell: bash diff --git a/.github/workflows/playwright-test-coverage.yml b/.github/workflows/playwright-test-coverage.yml index 24459ff3d01..d10cf8d664d 100644 --- a/.github/workflows/playwright-test-coverage.yml +++ b/.github/workflows/playwright-test-coverage.yml @@ -33,6 +33,7 @@ jobs: pnpm --filter n8n-playwright test:local \ --workers=${{ env.PLAYWRIGHT_WORKERS }} env: + BUILD_WITH_COVERAGE: 'true' CURRENTS_RECORD_KEY: ${{ secrets.CURRENTS_RECORD_KEY }} QA_PERFORMANCE_METRICS_WEBHOOK_URL: ${{ secrets.QA_PERFORMANCE_METRICS_WEBHOOK_URL }} QA_PERFORMANCE_METRICS_WEBHOOK_USER: ${{ secrets.QA_PERFORMANCE_METRICS_WEBHOOK_USER }} diff --git a/.github/workflows/playwright-test-reusable.yml b/.github/workflows/playwright-test-reusable.yml index 986f57d72a5..50d2f0baf2d 100644 --- a/.github/workflows/playwright-test-reusable.yml +++ b/.github/workflows/playwright-test-reusable.yml @@ -20,7 +20,7 @@ on: shards: description: 'Shards for parallel execution' required: false - default: '[1, 2, 3, 4, 5, 6, 7]' + default: '[1, 2, 3, 4, 5, 6, 7, 8]' type: string docker-image: description: 'Docker image to use (for docker-pull mode)' @@ -56,7 +56,7 @@ jobs: strategy: fail-fast: false matrix: - shard: ${{ fromJSON(inputs.shards || '[1, 2, 3, 4, 5, 6, 7]') }} + shard: ${{ fromJSON(inputs.shards || '[1, 2, 3, 4, 5, 6, 7, 8]') }} name: Test (Shard ${{ matrix.shard }}/${{ strategy.job-total }}) steps: diff --git a/.github/workflows/units-tests-reusable.yml b/.github/workflows/units-tests-reusable.yml index ecc5d51a3f3..07d12979c90 100644 --- a/.github/workflows/units-tests-reusable.yml +++ b/.github/workflows/units-tests-reusable.yml @@ -52,6 +52,7 @@ jobs: name: backend-unit - name: Upload coverage to Codecov + if: env.COVERAGE_ENABLED == 'true' uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -84,12 +85,46 @@ jobs: name: backend-integration - name: Upload coverage to Codecov + if: env.COVERAGE_ENABLED == 'true' uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} flags: backend-integration name: backend-integration + unit-test-nodes: + name: Nodes Unit Tests + runs-on: blacksmith-4vcpu-ubuntu-2204 + env: + COVERAGE_ENABLED: ${{ inputs.collectCoverage }} # Coverage collected when true + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ inputs.ref }} + + - name: Build + uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1 + with: + node-version: ${{ inputs.nodeVersion }} + + - name: Test Nodes + run: pnpm turbo test --filter=n8n-nodes-base + + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + name: nodes-unit + + - name: Upload coverage to Codecov + if: env.COVERAGE_ENABLED == 'true' + uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: nodes-unit + name: nodes-unit + unit-test-frontend: name: Frontend (${{ matrix.shard }}/2) runs-on: blacksmith-4vcpu-ubuntu-2204 @@ -122,6 +157,7 @@ jobs: name: frontend-shard-${{ matrix.shard }} - name: Upload coverage to Codecov + if: env.COVERAGE_ENABLED == 'true' uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -131,9 +167,9 @@ jobs: unit-test: name: Unit tests runs-on: ubuntu-latest - needs: [unit-test-backend, integration-test-backend, unit-test-frontend] + needs: [unit-test-backend, integration-test-backend, unit-test-nodes, unit-test-frontend] if: always() steps: - name: Fail if tests failed - if: needs.unit-test-backend.result == 'failure' || needs.integration-test-backend.result == 'failure' || needs.unit-test-frontend.result == 'failure' + if: needs.unit-test-backend.result == 'failure' || needs.integration-test-backend.result == 'failure' || needs.unit-test-nodes.result == 'failure' || needs.unit-test-frontend.result == 'failure' run: exit 1 diff --git a/docker/images/runners/Dockerfile b/docker/images/runners/Dockerfile index 9d5c59e3304..f525203aade 100644 --- a/docker/images/runners/Dockerfile +++ b/docker/images/runners/Dockerfile @@ -11,15 +11,6 @@ WORKDIR /app/task-runner-javascript RUN corepack enable pnpm -# Install extra runtime-only npm packages. Allow usage in the Code node via -# 'NODE_FUNCTION_ALLOW_EXTERNAL' env variable on n8n-task-runners.json. -RUN rm -f node_modules/.modules.yaml -RUN mv package.json package.json.bak -COPY docker/images/runners/package.json /app/task-runner-javascript/package.json -RUN pnpm install --prod --no-lockfile --silent -RUN mv package.json extras.json -RUN mv package.json.bak package.json - # Remove `catalog` and `workspace` references from package.json to allow `pnpm add` in extended images RUN node -e "const pkg = require('./package.json'); \ Object.keys(pkg.dependencies || {}).forEach(k => { \ @@ -79,11 +70,6 @@ RUN uv sync \ --all-extras \ --no-editable -# Install extra runtime-only Python packages. Allow usage in the Code node via -# 'N8N_RUNNERS_EXTERNAL_ALLOW' env variable on n8n-task-runners.json. -COPY docker/images/runners/extras.txt /app/task-runner-python/extras.txt -RUN uv pip install -r /app/task-runner-python/extras.txt - # ============================================================================== # STAGE 3: Task Runner Launcher download # ============================================================================== diff --git a/docker/images/runners/Dockerfile.distroless b/docker/images/runners/Dockerfile.distroless index 0e606be1eef..39fbfcf3ed1 100644 --- a/docker/images/runners/Dockerfile.distroless +++ b/docker/images/runners/Dockerfile.distroless @@ -26,14 +26,24 @@ WORKDIR /app/task-runner-javascript RUN corepack enable pnpm -# Install extra runtime-only npm packages. Allow usage in the Code node via -# 'NODE_FUNCTION_ALLOW_EXTERNAL' env variable on n8n-task-runners.json. -RUN rm -f node_modules/.modules.yaml -RUN mv package.json package.json.bak -COPY docker/images/runners/package.json /app/task-runner-javascript/package.json -RUN pnpm install --prod --no-lockfile --silent -RUN mv package.json extras.json -RUN mv package.json.bak package.json +# Remove `catalog` and `workspace` references from package.json to allow `pnpm add` +RUN node -e "const pkg = require('./package.json'); \ + Object.keys(pkg.dependencies || {}).forEach(k => { \ + const val = pkg.dependencies[k]; \ + if (val === 'catalog:' || val.startsWith('catalog:') || val.startsWith('workspace:')) \ + delete pkg.dependencies[k]; \ + }); \ + Object.keys(pkg.devDependencies || {}).forEach(k => { \ + const val = pkg.devDependencies[k]; \ + if (val === 'catalog:' || val.startsWith('catalog:') || val.startsWith('workspace:')) \ + delete pkg.devDependencies[k]; \ + }); \ + delete pkg.devDependencies; \ + require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2));" + +# Install moment by default (special case for n8n cloud) +RUN rm -f node_modules/.modules.yaml && \ + pnpm add moment@2.30.1 --prod --no-lockfile # ============================================================================== # STAGE 2: Python runner build (@n8n/task-runner-python) with uv @@ -81,11 +91,6 @@ RUN uv sync \ --all-extras \ --no-editable -# Install extra runtime-only Python packages. Allow usage in the Code node via -# 'N8N_RUNNERS_EXTERNAL_ALLOW' env variable on n8n-task-runners.json. -COPY docker/images/runners/extras.txt /app/task-runner-python/extras.txt -RUN uv pip install -r /app/task-runner-python/extras.txt - # ============================================================================== # STAGE 3: Task Runner Launcher download # ============================================================================== diff --git a/docker/images/runners/extras.txt b/docker/images/runners/extras.txt deleted file mode 100644 index a1d51a235fa..00000000000 --- a/docker/images/runners/extras.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Runtime-only extra Requirements File for installing dependencies in the Python task runner image. -# Installed at Docker image build time. Allow usage in the Code node -# via 'N8N_RUNNERS_EXTERNAL_ALLOW' env variable on n8n-task-runners.json. - -# numpy==2.3.2 diff --git a/docker/images/runners/package.json b/docker/images/runners/package.json deleted file mode 100644 index 53afeb3f90c..00000000000 --- a/docker/images/runners/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "task-runner-runtime-extras", - "description": "Runtime-only extra dependencies for installing packages in the JavaScript task runner image. Installed at Docker image build time. Allow usage in the Code node via 'NODE_FUNCTION_ALLOW_EXTERNAL' env variable on n8n-task-runners.json.", - "private": true, - "dependencies": { - "moment": "2.30.1" - } -} diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/chains/test-case-generator.ts b/packages/@n8n/ai-workflow-builder.ee/evaluations/chains/test-case-generator.ts index 2dc777dd503..1dff531f14b 100644 --- a/packages/@n8n/ai-workflow-builder.ee/evaluations/chains/test-case-generator.ts +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/chains/test-case-generator.ts @@ -98,37 +98,37 @@ export const basicTestCases: TestCase[] = [ id: 'multi-agent-research', name: 'Multi-agent research workflow', prompt: - 'Create a multi-agent AI workflow using GPT-4.1-mini where several agents work together to research a topic, fact-check the findings, and write a report that\'s sent as an HTML email. One agent should gather recent, credible information about the topic. Another agent should verify the facts and only mark something as "verified" if it appears in at least two independent sources. A third agent should combine the verified information into a clear, well-written report under 1,000 words. A final agent should edit and format the report to make it look clean and professional in the body of the email. Use Gmail to send the report.', + 'Create a multi-agent AI workflow using `gpt-4.1-mini` where several agents work together to research a topic, fact-check the findings, and write a report that\'s sent as an HTML email. One agent should gather recent, credible information about the topic. Another agent should verify the facts and only mark something as "verified" if it appears in at least two independent sources. A third agent should combine the verified information into a clear, well-written report under 1,000 words. A final agent should edit and format the report to make it look clean and professional in the body of the email. Use Gmail to send the report.', }, { id: 'email-summary', name: 'Summarize emails with AI', prompt: - 'Create an automation that runs on Monday mornings. It reads my Gmail inbox from the weekend, analyzes them with GPT-4.1-mini to find action items and priorities, and emails me a structured email using Gmail.', + 'Create an automation that runs on Monday mornings. It reads my Gmail inbox from the weekend, analyzes them with `gpt-4.1-mini` to find action items and priorities, and emails me a structured email using Gmail.', }, { id: 'ai-news-digest', name: 'Daily AI news digest', prompt: - 'Build an automation that runs every night 8pm. Use the NewsAPI "/everything" endpoint to search for AI-related news from the day. Pick the top 5 articles and use OpenAI GPT-4.1-mini to summarize each in two sentences. Generate an image using OpenAI based on the top article\'s summary. Send a structured Telegram message.', + 'Build an automation that runs every night 8pm. Use the NewsAPI "/everything" endpoint to search for AI-related news from the day. Pick the top 5 articles and use OpenAI `gpt-4.1-mini` to summarize each in two sentences. Generate an image using OpenAI based on the top article\'s summary. Send a structured Telegram message.', }, { id: 'daily-weather-report', name: 'Daily weather report', prompt: - 'Create an automation that checks the weather for my location every morning at 5 a.m using OpenWeather. Send me a short weather report by email using Gmail. Use OpenAI GPT-4.1-mini to write a short, fun formatted email body by adding personality when describing the weather and how the day might feel. Include all details relevant to decide on my plans and clothes for the day.', + 'Create an automation that checks the weather for my location every morning at 5 a.m using OpenWeather. Send me a short weather report by email using Gmail. Use OpenAI `gpt-4.1-mini` to write a short, fun formatted email body by adding personality when describing the weather and how the day might feel. Include all details relevant to decide on my plans and clothes for the day.', }, { id: 'invoice-pipeline', name: 'Invoice processing pipeline', prompt: - 'Create an invoice processing workflow using an n8n Form. When a user submits an invoice file (PDF or image) with their email address, use OpenAI GPT-4.1-mini to extract invoice data. Then, validate the date format is correct, the currency is valid, and the total amount is greater than zero. If validation fails, email the user a clear error message that explains which check failed from my Gmail. If the data passes validation, store the structured result in a datatable plus email the user. Every Monday morning, generate a weekly spending report using GPT-4.1-mini based on stored invoices and send a clean email using Gmail.', + 'Create an invoice processing workflow using an n8n Form. When a user submits an invoice file (PDF or image) with their email address, use OpenAI `gpt-4.1-mini` to extract invoice data. Then, validate the date format is correct, the currency is valid, and the total amount is greater than zero. If validation fails, email the user a clear error message that explains which check failed from my Gmail. If the data passes validation, store the structured result in a datatable plus email the user. Every Monday morning, generate a weekly spending report using `gpt-4.1-mini` based on stored invoices and send a clean email using Gmail.', }, { id: 'rag-assistant', name: 'RAG knowledge assistant', prompt: - 'Build an automation that creates a document-to-chat RAG pipeline. The workflow starts with an n8n Form where a user uploads one or more files (PDF, CSV, or JSON). Each upload should trigger a process that reads the file, splits it into chunks, and generates embeddings using OpenAI GPT-4.1-mini model, saved in one Pinecone table. Add a second part of the workflow for querying: use a Chat Message Trigger to act as a chatbot interface. When a user sends a question, retrieve the top 5 most relevant chunks from Pinecone, pass them into GPT-4.1-mini as context, and have it answer naturally using only the retrieved information. If a question can\'t be answered confidently, the bot should respond with: "I couldn\'t find that in the uploaded documents." Log each chat interaction in a Data Table with the user query, matched file(s), and timestamp. Send a daily summary email through Gmail showing total questions asked, top files referenced, and any failed lookups.', + 'Build an automation that creates a document-to-chat RAG pipeline. The workflow starts with an n8n Form where a user uploads one or more files (PDF, CSV, or JSON). Each upload should trigger a process that reads the file, splits it into chunks, and generates embeddings using OpenAI `gpt-4.1-mini` model, saved in one Pinecone table. Add a second part of the workflow for querying: use a Chat Message Trigger to act as a chatbot interface. When a user sends a question, retrieve the top 5 most relevant chunks from Pinecone, pass them into `gpt-4.1-mini` as context, and have it answer naturally using only the retrieved information. If a question can\'t be answered confidently, the bot should respond with: "I couldn\'t find that in the uploaded documents." Log each chat interaction in a Data Table with the user query, matched file(s), and timestamp. Send a daily summary email through Gmail showing total questions asked, top files referenced, and any failed lookups.', }, { id: 'lead-qualification', diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/prompts/main-agent.prompt.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/prompts/main-agent.prompt.ts index 2e71a479064..e69dc538dee 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/tools/prompts/main-agent.prompt.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/prompts/main-agent.prompt.ts @@ -37,7 +37,7 @@ Follow this proven sequence for creating robust workflows: - Why: Best practices help to inform which nodes to search for and use to build the workflow plus mistakes to avoid 2. **Discovery Phase** (parallel execution) - - Search for all required node types simultaneously + - Search for all required node types simultaneously, review the section for tips and best practices - Why: Ensures you work with actual available nodes, not assumptions 3. **Analysis Phase** (parallel execution) @@ -66,6 +66,14 @@ Follow this proven sequence for creating robust workflows: - Review and resolve any violations before finalizing - Why: Ensures structural issues are surfaced early; rerun validation after major updates + +When building AI workflows prefer the AI agent node to other text LLM nodes, unless the user specifies them by name. Summarization, analysis, information +extraction and classification can all be carried out by an AI agent node, correct system prompt, and structured output parser. +For the purposes of this section provider specific nodes can be described as nodes like @n8n/n8n-nodes-langchain.openAi. +Do not use provider specific nodes for text operations - instead use an AI agent node. +For generation/analysis of content other than text (images, video, audio) provider specific nodes should be used. + + Enforcing best practice compliance is MANDATORY diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index feb311e8800..cdd63b7252d 100644 --- a/packages/@n8n/api-types/package.json +++ b/packages/@n8n/api-types/package.json @@ -12,6 +12,7 @@ "lint:fix": "eslint . --fix", "watch": "tsc -p tsconfig.build.json --watch", "test": "jest", + "test:unit": "jest", "test:dev": "jest --watch" }, "main": "dist/index.js", diff --git a/packages/@n8n/api-types/src/chat-hub.ts b/packages/@n8n/api-types/src/chat-hub.ts index 09998c0aa53..11442748210 100644 --- a/packages/@n8n/api-types/src/chat-hub.ts +++ b/packages/@n8n/api-types/src/chat-hub.ts @@ -17,6 +17,9 @@ export const chatHubLLMProviderSchema = z.enum([ 'google', 'azureOpenAi', 'ollama', + 'awsBedrock', + 'cohere', + 'mistralCloud', ]); export type ChatHubLLMProvider = z.infer; @@ -40,6 +43,9 @@ export const PROVIDER_CREDENTIAL_TYPE_MAP: Record< google: 'googlePalmApi', ollama: 'ollamaApi', azureOpenAi: 'azureOpenAiApi', + awsBedrock: 'aws', + cohere: 'cohereApi', + mistralCloud: 'mistralCloudApi', }; export type ChatHubAgentTool = typeof JINA_AI_TOOL_NODE_TYPE | typeof SEAR_XNG_TOOL_NODE_TYPE; @@ -72,6 +78,21 @@ const ollamaModelSchema = z.object({ model: z.string(), }); +const awsBedrockModelSchema = z.object({ + provider: z.literal('awsBedrock'), + model: z.string(), +}); + +const cohereModelSchema = z.object({ + provider: z.literal('cohere'), + model: z.string(), +}); + +const mistralCloudModelSchema = z.object({ + provider: z.literal('mistralCloud'), + model: z.string(), +}); + const n8nModelSchema = z.object({ provider: z.literal('n8n'), workflowId: z.string(), @@ -88,6 +109,9 @@ export const chatHubConversationModelSchema = z.discriminatedUnion('provider', [ googleModelSchema, azureOpenAIModelSchema, ollamaModelSchema, + awsBedrockModelSchema, + cohereModelSchema, + mistralCloudModelSchema, n8nModelSchema, chatAgentSchema, ]); @@ -97,12 +121,18 @@ export type ChatHubAnthropicModel = z.infer; export type ChatHubGoogleModel = z.infer; export type ChatHubAzureOpenAIModel = z.infer; export type ChatHubOllamaModel = z.infer; +export type ChatHubAwsBedrockModel = z.infer; +export type ChatHubCohereModel = z.infer; +export type ChatHubMistralCloudModel = z.infer; export type ChatHubBaseLLMModel = | ChatHubOpenAIModel | ChatHubAnthropicModel | ChatHubGoogleModel | ChatHubAzureOpenAIModel - | ChatHubOllamaModel; + | ChatHubOllamaModel + | ChatHubAwsBedrockModel + | ChatHubCohereModel + | ChatHubMistralCloudModel; export type ChatHubN8nModel = z.infer; export type ChatHubCustomAgentModel = z.infer; @@ -124,6 +154,7 @@ export interface ChatModelDto { description: string | null; updatedAt: string | null; createdAt: string | null; + allowFileUploads?: boolean; } /** @@ -143,11 +174,26 @@ export const emptyChatModelsResponse: ChatModelsResponse = { google: { models: [] }, azureOpenAi: { models: [] }, ollama: { models: [] }, + awsBedrock: { models: [] }, + cohere: { models: [] }, + mistralCloud: { models: [] }, n8n: { models: [] }, // eslint-disable-next-line @typescript-eslint/naming-convention 'custom-agent': { models: [] }, }; +/** + * Chat attachment schema for incoming requests. + * Requires base64 data and fileName. + * MimeType, fileType, fileExtension, and fileSize are populated server-side. + */ +export const chatAttachmentSchema = z.object({ + data: z.string(), + fileName: z.string(), +}); + +export type ChatAttachment = z.infer; + export class ChatHubSendMessageRequest extends Z.class({ messageId: z.string().uuid(), sessionId: z.string().uuid(), @@ -161,6 +207,7 @@ export class ChatHubSendMessageRequest extends Z.class({ }), ), tools: z.array(INodeSchema), + attachments: z.array(chatAttachmentSchema), }) {} export class ChatHubRegenerateMessageRequest extends Z.class({ @@ -235,9 +282,20 @@ export interface ChatHubMessageDto { previousMessageId: ChatMessageId | null; retryOfMessageId: ChatMessageId | null; revisionOfMessageId: ChatMessageId | null; + + attachments: Array<{ fileName?: string; mimeType?: string }>; } -export type ChatHubConversationsResponse = ChatHubSessionDto[]; +export class ChatHubConversationsRequest extends Z.class({ + limit: z.coerce.number().int().min(1).max(100), + cursor: z.string().uuid().optional(), +}) {} + +export interface ChatHubConversationsResponse { + data: ChatHubSessionDto[]; + nextCursor: string | null; + hasMore: boolean; +} export interface ChatHubConversationDto { messages: Record; diff --git a/packages/@n8n/api-types/src/dto/workflows/__tests__/import-workflow-from-url.dto.test.ts b/packages/@n8n/api-types/src/dto/workflows/__tests__/import-workflow-from-url.dto.test.ts index 20657a13c5a..57b2ce84c1e 100644 --- a/packages/@n8n/api-types/src/dto/workflows/__tests__/import-workflow-from-url.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/workflows/__tests__/import-workflow-from-url.dto.test.ts @@ -6,25 +6,30 @@ describe('ImportWorkflowFromUrlDto', () => { { name: 'valid URL with .json extension', url: 'https://example.com/workflow.json', + projectId: '12345', }, { name: 'valid URL without .json extension', url: 'https://example.com/workflow', + projectId: '12345', }, { name: 'valid URL with query parameters', url: 'https://example.com/workflow.json?param=value', + projectId: '12345', }, { name: 'valid URL with fragments', url: 'https://example.com/workflow.json#section', + projectId: '12345', }, { name: 'valid API endpoint URL', url: 'https://api.example.com/v1/workflows/123', + projectId: '12345', }, - ])('should validate $name', ({ url }) => { - const result = ImportWorkflowFromUrlDto.safeParse({ url }); + ])('should validate $name', ({ url, projectId }) => { + const result = ImportWorkflowFromUrlDto.safeParse({ url, projectId }); expect(result.success).toBe(true); }); }); diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 75d8a3079fa..1f2646f4518 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -27,10 +27,13 @@ export { emptyChatModelsResponse, type ChatModelsRequest, type ChatModelsResponse, + chatAttachmentSchema, + type ChatAttachment, ChatHubSendMessageRequest, ChatHubRegenerateMessageRequest, ChatHubEditMessageRequest, ChatHubUpdateConversationRequest, + ChatHubConversationsRequest, type ChatMessageId, type ChatSessionId, type ChatHubMessageDto, diff --git a/packages/@n8n/backend-common/package.json b/packages/@n8n/backend-common/package.json index 82b2547bbd1..2d6e2517122 100644 --- a/packages/@n8n/backend-common/package.json +++ b/packages/@n8n/backend-common/package.json @@ -12,6 +12,7 @@ "lint:fix": "eslint . --fix", "watch": "tsc -p tsconfig.build.json --watch", "test": "jest", + "test:unit": "jest", "test:dev": "jest --watch" }, "main": "dist/index.js", diff --git a/packages/@n8n/backend-test-utils/src/db/workflows.ts b/packages/@n8n/backend-test-utils/src/db/workflows.ts index 3682404a47e..d9f03b3f295 100644 --- a/packages/@n8n/backend-test-utils/src/db/workflows.ts +++ b/packages/@n8n/backend-test-utils/src/db/workflows.ts @@ -89,6 +89,18 @@ export async function createManyWorkflows( return await Promise.all(workflowRequests); } +export async function createManyActiveWorkflows( + amount: number, + attributes: Partial = {}, + userOrProject?: User | Project, +) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const workflowRequests = [...Array(amount)].map( + async (_) => await createActiveWorkflow(attributes, userOrProject), + ); + return await Promise.all(workflowRequests); +} + export async function shareWorkflowWithUsers(workflow: IWorkflowBase, users: User[]) { const sharedWorkflows: Array> = await Promise.all( users.map(async (user) => { @@ -135,7 +147,7 @@ export async function getWorkflowSharing(workflow: IWorkflowBase) { */ export async function createWorkflowWithTrigger( attributes: Partial = {}, - user?: User, + userOrProject?: User | Project, ) { const workflow = await createWorkflow( { @@ -170,7 +182,7 @@ export async function createWorkflowWithTrigger( }, ...attributes, }, - user, + userOrProject, ); return workflow; @@ -201,12 +213,12 @@ export async function createWorkflowWithHistory( */ export async function createWorkflowWithTriggerAndHistory( attributes: Partial = {}, - user?: User, + userOrProject?: User | Project, ) { - const workflow = await createWorkflowWithTrigger(attributes, user); + const workflow = await createWorkflowWithTrigger(attributes, userOrProject); // Create workflow history for the initial version - await createWorkflowHistory(workflow, user); + await createWorkflowHistory(workflow, userOrProject); return workflow; } @@ -227,12 +239,78 @@ export const getWorkflowById = async (id: string) => * @param workflow workflow to create history for * @param user user who created the version (optional) */ -export async function createWorkflowHistory(workflow: IWorkflowDb, user?: User): Promise { +export async function createWorkflowHistory( + workflow: IWorkflowDb, + userOrProject?: User | Project, +): Promise { await Container.get(WorkflowHistoryRepository).insert({ workflowId: workflow.id, versionId: workflow.versionId, nodes: workflow.nodes, connections: workflow.connections, - authors: user?.email ?? 'test@example.com', + authors: userOrProject instanceof User ? userOrProject.email : 'test@example.com', }); } + +/** + * Set the active version for a workflow + * @param workflowId workflow ID + * @param versionId version ID to set as active + */ +export async function setActiveVersion(workflowId: string, versionId: string): Promise { + await Container.get(WorkflowRepository) + .createQueryBuilder() + .update() + .set({ activeVersionId: versionId }) + .where('id = :workflowId', { workflowId }) + .execute(); +} + +/** + * Create an active workflow with trigger, history, and activeVersionId set to the current version. + * This simulates a workflow that has been activated and is running. + * @param attributes workflow attributes + * @param user user to assign the workflow to + */ +export async function createActiveWorkflow( + attributes: Partial = {}, + userOrProject?: User | Project, +) { + const workflow = await createWorkflowWithTriggerAndHistory( + { active: true, ...attributes }, + userOrProject, + ); + + await setActiveVersion(workflow.id, workflow.versionId); + + workflow.activeVersionId = workflow.versionId; + return workflow; +} + +/** + * Create a workflow with a specific active version. + * This simulates a workflow where the active version differs from the current version. + * @param activeVersionId the version ID to set as active + * @param attributes workflow attributes + * @param user user to assign the workflow to + */ +export async function createWorkflowWithActiveVersion( + activeVersionId: string, + attributes: Partial = {}, + user?: User, +) { + const workflow = await createWorkflowWithTriggerAndHistory({ active: true, ...attributes }, user); + + await Container.get(WorkflowHistoryRepository).insert({ + workflowId: workflow.id, + versionId: activeVersionId, + nodes: workflow.nodes, + connections: workflow.connections, + authors: user?.email ?? 'test@example.com', + }); + + await setActiveVersion(workflow.id, activeVersionId); + + workflow.activeVersionId = activeVersionId; + return workflow; +} diff --git a/packages/@n8n/backend-test-utils/src/test-db.ts b/packages/@n8n/backend-test-utils/src/test-db.ts index 3a1a657ce79..c6fee2a8daa 100644 --- a/packages/@n8n/backend-test-utils/src/test-db.ts +++ b/packages/@n8n/backend-test-utils/src/test-db.ts @@ -94,8 +94,44 @@ type EntityName = */ export async function truncate(entities: EntityName[]) { const connection = Container.get(Connection); + const dbType = connection.options.type; - for (const name of entities) { - await connection.getRepository(name).delete({}); + // Disable FK checks for MySQL/MariaDB to handle circular dependencies + if (dbType === 'mysql' || dbType === 'mariadb') { + await connection.query('SET FOREIGN_KEY_CHECKS=0'); + } + + try { + // Collect junction tables to clean + const junctionTablesToClean = new Set(); + + // Find all junction tables associated with the entities being truncated + for (const name of entities) { + try { + const metadata = connection.getMetadata(name); + for (const relation of metadata.manyToManyRelations) { + if (relation.junctionEntityMetadata) { + const junctionTableName = relation.junctionEntityMetadata.tablePath; + junctionTablesToClean.add(junctionTableName); + } + } + } catch (error) { + // Skip + } + } + + // Clean junction tables first (since they reference the entities) + for (const tableName of junctionTablesToClean) { + await connection.query(`DELETE FROM ${tableName}`); + } + + for (const name of entities) { + await connection.getRepository(name).delete({}); + } + } finally { + // Re-enable FK checks + if (dbType === 'mysql' || dbType === 'mariadb') { + await connection.query('SET FOREIGN_KEY_CHECKS=1'); + } } } diff --git a/packages/@n8n/client-oauth2/package.json b/packages/@n8n/client-oauth2/package.json index 302eb340bba..e913114fad3 100644 --- a/packages/@n8n/client-oauth2/package.json +++ b/packages/@n8n/client-oauth2/package.json @@ -12,6 +12,7 @@ "lint:fix": "eslint . --fix", "watch": "tsc -p tsconfig.build.json --watch", "test": "jest", + "test:unit": "jest", "test:dev": "jest --watch" }, "main": "dist/index.js", diff --git a/packages/@n8n/codemirror-lang-sql/package.json b/packages/@n8n/codemirror-lang-sql/package.json index b1f08212588..794649d8fed 100644 --- a/packages/@n8n/codemirror-lang-sql/package.json +++ b/packages/@n8n/codemirror-lang-sql/package.json @@ -8,6 +8,7 @@ "generate:sql:grammar": "lezer-generator --typeScript --output src/grammar.sql.ts src/sql.grammar", "generate": "pnpm generate:sql:grammar && pnpm format", "build": "tsc -p tsconfig.build.json", + "test:unit": "jest", "lint": "eslint . --quiet", "lint:fix": "eslint . --fix", "format": "biome format --write src test", diff --git a/packages/@n8n/codemirror-lang/package.json b/packages/@n8n/codemirror-lang/package.json index 737ed74acfe..6b7ca979b66 100644 --- a/packages/@n8n/codemirror-lang/package.json +++ b/packages/@n8n/codemirror-lang/package.json @@ -22,6 +22,7 @@ "generate": "pnpm generate:expressions:grammar && pnpm format", "build": "tsc -p tsconfig.build.json", "test": "jest", + "test:unit": "jest", "lint": "eslint . --quiet", "lint:fix": "eslint . --fix", "format": "biome format --write src test", diff --git a/packages/@n8n/config/package.json b/packages/@n8n/config/package.json index f706af989b9..316c3ff4600 100644 --- a/packages/@n8n/config/package.json +++ b/packages/@n8n/config/package.json @@ -12,6 +12,7 @@ "lint:fix": "eslint . --fix", "watch": "tsc -p tsconfig.build.json --watch", "test": "jest", + "test:unit": "jest", "test:dev": "jest --watch" }, "main": "dist/index.js", diff --git a/packages/@n8n/db/package.json b/packages/@n8n/db/package.json index 69f7b464747..decae683752 100644 --- a/packages/@n8n/db/package.json +++ b/packages/@n8n/db/package.json @@ -12,6 +12,7 @@ "lint:fix": "eslint . --fix", "watch": "tsc -p tsconfig.build.json --watch", "test": "jest", + "test:unit": "jest", "test:dev": "jest --watch" }, "main": "dist/index.js", diff --git a/packages/@n8n/db/src/entities/types-db.ts b/packages/@n8n/db/src/entities/types-db.ts index b9782f38394..82f3dba355e 100644 --- a/packages/@n8n/db/src/entities/types-db.ts +++ b/packages/@n8n/db/src/entities/types-db.ts @@ -26,6 +26,7 @@ import type { SharedWorkflow } from './shared-workflow'; import type { TagEntity } from './tag-entity'; import type { User } from './user'; import type { WorkflowEntity } from './workflow-entity'; +import type { WorkflowHistory } from './workflow-history'; export type UsageCount = { usageCount: number; @@ -79,6 +80,7 @@ export interface IWorkflowDb extends IWorkflowBase { triggerCount: number; tags?: TagEntity[]; parentFolder?: Folder | null; + activeVersion?: WorkflowHistory | null; } export interface ICredentialsDb extends ICredentialsBase, ICredentialsEncrypted { @@ -221,6 +223,7 @@ export namespace ListQueryDb { | 'name' | 'active' | 'versionId' + | 'activeVersionId' | 'createdAt' | 'updatedAt' | 'tags' diff --git a/packages/@n8n/db/src/entities/workflow-entity.ts b/packages/@n8n/db/src/entities/workflow-entity.ts index 026a0efeb12..3392a9ceac8 100644 --- a/packages/@n8n/db/src/entities/workflow-entity.ts +++ b/packages/@n8n/db/src/entities/workflow-entity.ts @@ -18,6 +18,7 @@ import type { SharedWorkflow } from './shared-workflow'; import type { TagEntity } from './tag-entity'; import type { TestRun } from './test-run.ee'; import type { ISimplifiedPinData, IWorkflowDb } from './types-db'; +import type { WorkflowHistory } from './workflow-history'; import type { WorkflowStatistics } from './workflow-statistics'; import type { WorkflowTagMapping } from './workflow-tag-mapping'; import { objectRetriever, sqlite } from '../utils/transformers'; @@ -103,6 +104,13 @@ export class WorkflowEntity extends WithTimestampsAndStringId implements IWorkfl @Column({ length: 36 }) versionId: string; + @Column({ name: 'activeVersionId', length: 36, nullable: true }) + activeVersionId: string | null; + + @ManyToOne('WorkflowHistory', { nullable: true }) + @JoinColumn({ name: 'activeVersionId', referencedColumnName: 'versionId' }) + activeVersion: WorkflowHistory | null; + @Column({ default: 1 }) versionCounter: number; diff --git a/packages/@n8n/db/src/migrations/common/1761773155024-AddAttachmentsToChatHubMessages.ts b/packages/@n8n/db/src/migrations/common/1761773155024-AddAttachmentsToChatHubMessages.ts new file mode 100644 index 00000000000..732f569d31b --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1761773155024-AddAttachmentsToChatHubMessages.ts @@ -0,0 +1,19 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const table = { + messages: 'chat_hub_messages', +} as const; + +export class AddAttachmentsToChatHubMessages1761773155024 implements ReversibleMigration { + async up({ schemaBuilder: { addColumns, column } }: MigrationContext) { + await addColumns(table.messages, [ + column('attachments').json.comment( + 'File attachments for the message (if any), stored as JSON. Files are stored as base64-encoded data URLs.', + ), + ]); + } + + async down({ schemaBuilder: { dropColumns } }: MigrationContext) { + await dropColumns(table.messages, ['attachments']); + } +} diff --git a/packages/@n8n/db/src/migrations/common/1763047800000-AddActiveVersionIdColumn.ts b/packages/@n8n/db/src/migrations/common/1763047800000-AddActiveVersionIdColumn.ts new file mode 100644 index 00000000000..c4c44513b61 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1763047800000-AddActiveVersionIdColumn.ts @@ -0,0 +1,43 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const WORKFLOWS_TABLE_NAME = 'workflow_entity'; +const WORKFLOW_HISTORY_TABLE_NAME = 'workflow_history'; + +export class AddActiveVersionIdColumn1763047800000 implements ReversibleMigration { + async up({ + schemaBuilder: { addColumns, column, addForeignKey }, + queryRunner, + escape, + }: MigrationContext) { + const workflowsTableName = escape.tableName(WORKFLOWS_TABLE_NAME); + + await addColumns(WORKFLOWS_TABLE_NAME, [column('activeVersionId').varchar(36)]); + + await addForeignKey( + WORKFLOWS_TABLE_NAME, + 'activeVersionId', + [WORKFLOW_HISTORY_TABLE_NAME, 'versionId'], + undefined, + 'RESTRICT', + ); + + // For existing ACTIVE workflows, set activeVersionId = versionId + const versionIdColumn = escape.columnName('versionId'); + const activeColumn = escape.columnName('active'); + const activeVersionIdColumn = escape.columnName('activeVersionId'); + + await queryRunner.query( + `UPDATE ${workflowsTableName} + SET ${activeVersionIdColumn} = ${versionIdColumn} + WHERE ${activeColumn} = true`, + ); + } + + async down({ schemaBuilder: { dropColumns, dropForeignKey } }: MigrationContext) { + await dropForeignKey(WORKFLOWS_TABLE_NAME, 'activeVersionId', [ + WORKFLOW_HISTORY_TABLE_NAME, + 'versionId', + ]); + await dropColumns(WORKFLOWS_TABLE_NAME, ['activeVersionId']); + } +} diff --git a/packages/@n8n/db/src/migrations/common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar.ts b/packages/@n8n/db/src/migrations/common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar.ts new file mode 100644 index 00000000000..7e22cee7502 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar.ts @@ -0,0 +1,69 @@ +import type { IrreversibleMigration, MigrationContext } from '../migration-types'; + +const TABLE_NAME = 'oauth_authorization_codes'; +const TEMP_TABLE_NAME = 'temp_oauth_authorization_codes'; + +export class ChangeOAuthStateColumnToUnboundedVarchar1763572724000 + implements IrreversibleMigration +{ + async up({ + isSqlite, + isMysql, + isPostgres, + escape, + copyTable, + queryRunner, + schemaBuilder: { createTable, column, dropTable }, + }: MigrationContext) { + const tableName = escape.tableName(TABLE_NAME); + + if (isSqlite) { + const tempTableName = escape.tableName(TEMP_TABLE_NAME); + + await createTable(TEMP_TABLE_NAME) + .withColumns( + column('code').varchar(255).primary.notNull, + column('clientId').varchar().notNull, + column('userId').uuid.notNull, + column('redirectUri').varchar().notNull, + column('codeChallenge').varchar().notNull, + column('codeChallengeMethod').varchar(255).notNull, + column('expiresAt').bigint.notNull.comment('Unix timestamp in milliseconds'), + column('state').varchar(), + column('used').bool.notNull.default(false), + ) + .withForeignKey('clientId', { + tableName: 'oauth_clients', + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('userId', { + tableName: 'user', + columnName: 'id', + onDelete: 'CASCADE', + }).withTimestamps; + + await copyTable(TABLE_NAME, TEMP_TABLE_NAME); + + await dropTable(TABLE_NAME); + + await queryRunner.query(`ALTER TABLE ${tempTableName} RENAME TO ${tableName};`); + } else if (isMysql) { + await queryRunner.query( + `ALTER TABLE ${tableName} MODIFY COLUMN ${escape.columnName('state')} TEXT;`, + ); + await queryRunner.query( + `ALTER TABLE ${tableName} MODIFY COLUMN ${escape.columnName('codeChallenge')} TEXT NOT NULL;`, + ); + await queryRunner.query( + `ALTER TABLE ${tableName} MODIFY COLUMN ${escape.columnName('redirectUri')} TEXT NOT NULL;`, + ); + } else if (isPostgres) { + await queryRunner.query( + `ALTER TABLE ${tableName} ALTER COLUMN ${escape.columnName('state')} TYPE VARCHAR,` + + ` ALTER COLUMN ${escape.columnName('codeChallenge')} TYPE VARCHAR,` + + ` ALTER COLUMN ${escape.columnName('redirectUri')} TYPE VARCHAR;`, + ); + } + } +} diff --git a/packages/@n8n/db/src/migrations/mysqldb/index.ts b/packages/@n8n/db/src/migrations/mysqldb/index.ts index abebfdae511..78388f1f7af 100644 --- a/packages/@n8n/db/src/migrations/mysqldb/index.ts +++ b/packages/@n8n/db/src/migrations/mysqldb/index.ts @@ -1,5 +1,3 @@ -import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns'; -import { AddWorkflowHistoryAutoSaveFields1762847206508 } from './../common/1762847206508-AddWorkflowHistoryAutoSaveFields'; import { InitialMigration1588157391238 } from './1588157391238-InitialMigration'; import { WebhookModel1592447867632 } from './1592447867632-WebhookModel'; import { CreateIndexStoppedAt1594902918301 } from './1594902918301-CreateIndexStoppedAt'; @@ -55,6 +53,7 @@ import { AddToolsColumnToChatHubTables1761830340990 } from './1761830340990-AddT import { CreateLdapEntities1674509946020 } from '../common/1674509946020-CreateLdapEntities'; import { PurgeInvalidWorkflowConnections1675940580449 } from '../common/1675940580449-PurgeInvalidWorkflowConnections'; import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns'; +import { AddMfaColumns1690000000030 } from '../common/1690000000040-AddMfaColumns'; import { CreateWorkflowNameIndex1691088862123 } from '../common/1691088862123-CreateWorkflowNameIndex'; import { CreateWorkflowHistoryTable1692967111175 } from '../common/1692967111175-CreateWorkflowHistoryTable'; import { ExecutionSoftDelete1693491613982 } from '../common/1693491613982-ExecutionSoftDelete'; @@ -111,8 +110,12 @@ import { CreateChatHubAgentTable1760020000000 } from '../common/1760020000000-Cr import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRoleNames'; import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities'; import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable'; +import { AddAttachmentsToChatHubMessages1761773155024 } from '../common/1761773155024-AddAttachmentsToChatHubMessages'; import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn'; import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords'; +import { AddWorkflowHistoryAutoSaveFields1762847206508 } from '../common/1762847206508-AddWorkflowHistoryAutoSaveFields'; +import { AddActiveVersionIdColumn1763047800000 } from '../common/1763047800000-AddActiveVersionIdColumn'; +import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar'; import type { Migration } from '../migration-types'; export const mysqlMigrations: Migration[] = [ @@ -231,4 +234,7 @@ export const mysqlMigrations: Migration[] = [ BackfillMissingWorkflowHistoryRecords1762763704614, AddWorkflowHistoryAutoSaveFields1762847206508, AddToolsColumnToChatHubTables1761830340990, + ChangeOAuthStateColumnToUnboundedVarchar1763572724000, + AddAttachmentsToChatHubMessages1761773155024, + AddActiveVersionIdColumn1763047800000, ]; diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index 6e76c106dc7..90a69d8a4c2 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -109,10 +109,13 @@ import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRole import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities'; import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable'; import { DropUnusedChatHubColumns1760965142113 } from '../common/1760965142113-DropUnusedChatHubColumns'; +import { AddAttachmentsToChatHubMessages1761773155024 } from '../common/1761773155024-AddAttachmentsToChatHubMessages'; import { AddToolsColumnToChatHubTables1761830340990 } from '../common/1761830340990-AddToolsColumnToChatHubTables'; import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn'; import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords'; import { AddWorkflowHistoryAutoSaveFields1762847206508 } from '../common/1762847206508-AddWorkflowHistoryAutoSaveFields'; +import { AddActiveVersionIdColumn1763047800000 } from '../common/1763047800000-AddActiveVersionIdColumn'; +import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar'; import type { Migration } from '../migration-types'; export const postgresMigrations: Migration[] = [ @@ -231,4 +234,7 @@ export const postgresMigrations: Migration[] = [ ChangeDefaultForIdInUserTable1762771264000, AddWorkflowHistoryAutoSaveFields1762847206508, AddToolsColumnToChatHubTables1761830340990, + ChangeOAuthStateColumnToUnboundedVarchar1763572724000, + AddAttachmentsToChatHubMessages1761773155024, + AddActiveVersionIdColumn1763047800000, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index 5db7f08d5e7..27fde9d26d0 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -105,10 +105,13 @@ import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRole import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities'; import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable'; import { DropUnusedChatHubColumns1760965142113 } from '../common/1760965142113-DropUnusedChatHubColumns'; +import { AddAttachmentsToChatHubMessages1761773155024 } from '../common/1761773155024-AddAttachmentsToChatHubMessages'; import { AddToolsColumnToChatHubTables1761830340990 } from '../common/1761830340990-AddToolsColumnToChatHubTables'; import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn'; import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords'; import { AddWorkflowHistoryAutoSaveFields1762847206508 } from '../common/1762847206508-AddWorkflowHistoryAutoSaveFields'; +import { AddActiveVersionIdColumn1763047800000 } from '../common/1763047800000-AddActiveVersionIdColumn'; +import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar'; import type { Migration } from '../migration-types'; const sqliteMigrations: Migration[] = [ @@ -223,6 +226,9 @@ const sqliteMigrations: Migration[] = [ BackfillMissingWorkflowHistoryRecords1762763704614, AddWorkflowHistoryAutoSaveFields1762847206508, AddToolsColumnToChatHubTables1761830340990, + ChangeOAuthStateColumnToUnboundedVarchar1763572724000, + AddAttachmentsToChatHubMessages1761773155024, + AddActiveVersionIdColumn1763047800000, ]; export { sqliteMigrations }; diff --git a/packages/@n8n/db/src/repositories/__tests__/workflow.repository.test.ts b/packages/@n8n/db/src/repositories/__tests__/workflow.repository.test.ts index ea8afb8ba6a..d6f3691e2ce 100644 --- a/packages/@n8n/db/src/repositories/__tests__/workflow.repository.test.ts +++ b/packages/@n8n/db/src/repositories/__tests__/workflow.repository.test.ts @@ -249,9 +249,7 @@ describe('WorkflowRepository', () => { }), ); - expect(queryBuilder.andWhere).toHaveBeenCalledWith('workflow.active = :active', { - active: true, - }); + expect(queryBuilder.andWhere).toHaveBeenCalledWith('workflow.activeVersionId IS NOT NULL'); expect(queryBuilder.innerJoin).toHaveBeenCalledWith('workflow.shared', 'shared'); expect(queryBuilder.andWhere).toHaveBeenCalledWith('shared.projectId = :projectId', { diff --git a/packages/@n8n/db/src/repositories/execution.repository.ts b/packages/@n8n/db/src/repositories/execution.repository.ts index 7a9b181560c..df16215e7ba 100644 --- a/packages/@n8n/db/src/repositories/execution.repository.ts +++ b/packages/@n8n/db/src/repositories/execution.repository.ts @@ -408,7 +408,7 @@ export class ExecutionRepository extends Repository { async hardDelete(ids: { workflowId: string; executionId: string }) { return await Promise.all([ this.delete(ids.executionId), - this.binaryDataService.deleteMany([ids]), + this.binaryDataService.deleteMany([{ type: 'execution', ...ids }]), ]); } @@ -509,6 +509,7 @@ export class ExecutionRepository extends Repository { } const ids = executions.map(({ id, workflowId }) => ({ + type: 'execution' as const, executionId: id, workflowId, })); @@ -605,7 +606,11 @@ export class ExecutionRepository extends Repository { */ withDeleted: true, }) - ).map(({ id: executionId, workflowId }) => ({ workflowId, executionId })); + ).map(({ id: executionId, workflowId }) => ({ + type: 'execution' as const, + workflowId, + executionId, + })); return workflowIdsAndExecutionIds; } diff --git a/packages/@n8n/db/src/repositories/license-metrics.repository.ts b/packages/@n8n/db/src/repositories/license-metrics.repository.ts index c20c0c1091d..3ccf80680f8 100644 --- a/packages/@n8n/db/src/repositories/license-metrics.repository.ts +++ b/packages/@n8n/db/src/repositories/license-metrics.repository.ts @@ -58,7 +58,7 @@ export class LicenseMetricsRepository extends Repository { SELECT (SELECT COUNT(*) FROM ${userTable} WHERE disabled = false) AS enabled_user_count, (SELECT COUNT(*) FROM ${userTable}) AS total_user_count, - (SELECT COUNT(*) FROM ${workflowTable} WHERE active = true) AS active_workflow_count, + (SELECT COUNT(*) FROM ${workflowTable} WHERE ${this.toColumnName('activeVersionId')} IS NOT NULL) AS active_workflow_count, (SELECT COUNT(*) FROM ${workflowTable}) AS total_workflow_count, (SELECT COUNT(*) FROM ${credentialTable}) AS total_credentials_count, (SELECT SUM(count) FROM ${workflowStatsTable} WHERE name IN ('production_success', 'production_error')) AS production_executions_count, diff --git a/packages/@n8n/db/src/repositories/shared-workflow.repository.ts b/packages/@n8n/db/src/repositories/shared-workflow.repository.ts index 3311ef9aa4e..ebe905aa9a9 100644 --- a/packages/@n8n/db/src/repositories/shared-workflow.repository.ts +++ b/packages/@n8n/db/src/repositories/shared-workflow.repository.ts @@ -149,6 +149,7 @@ export class SharedWorkflowRepository extends Repository { where?: FindOptionsWhere; includeTags?: boolean; includeParentFolder?: boolean; + includeActiveVersion?: boolean; em?: EntityManager; } = {}, ) { @@ -156,6 +157,7 @@ export class SharedWorkflowRepository extends Repository { where = {}, includeTags = false, includeParentFolder = false, + includeActiveVersion = false, em = this.manager, } = options; @@ -169,6 +171,7 @@ export class SharedWorkflowRepository extends Repository { shared: { project: { projectRelations: { user: true } } }, tags: includeTags, parentFolder: includeParentFolder, + activeVersion: includeActiveVersion, }, }, }); diff --git a/packages/@n8n/db/src/repositories/workflow-history.repository.ts b/packages/@n8n/db/src/repositories/workflow-history.repository.ts index 9127e271fdc..d552813321a 100644 --- a/packages/@n8n/db/src/repositories/workflow-history.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow-history.repository.ts @@ -14,9 +14,9 @@ export class WorkflowHistoryRepository extends Repository { } /** - * Delete workflow history records earlier than a given date, except for current workflow versions. + * Delete workflow history records earlier than a given date, except for current and active workflow versions. */ - async deleteEarlierThanExceptCurrent(date: Date) { + async deleteEarlierThanExceptCurrentAndActive(date: Date) { const currentVersionIdsSubquery = this.manager .createQueryBuilder() .subQuery() @@ -24,12 +24,21 @@ export class WorkflowHistoryRepository extends Repository { .from(WorkflowEntity, 'w') .getQuery(); + const activeVersionIdsSubquery = this.manager + .createQueryBuilder() + .subQuery() + .select('w.activeVersionId') + .from(WorkflowEntity, 'w') + .where('w.activeVersionId IS NOT NULL') + .getQuery(); + return await this.manager .createQueryBuilder() .delete() .from(WorkflowHistory) .where('createdAt < :date', { date }) .andWhere(`versionId NOT IN (${currentVersionIdsSubquery})`) + .andWhere(`versionId NOT IN (${activeVersionIdsSubquery})`) .execute(); } } diff --git a/packages/@n8n/db/src/repositories/workflow-statistics.repository.ts b/packages/@n8n/db/src/repositories/workflow-statistics.repository.ts index 26b7e1c5c1d..be470904c51 100644 --- a/packages/@n8n/db/src/repositories/workflow-statistics.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow-statistics.repository.ts @@ -1,7 +1,14 @@ import { GlobalConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; -import { DataSource, MoreThanOrEqual, QueryFailedError, Repository } from '@n8n/typeorm'; +import { + DataSource, + IsNull, + MoreThanOrEqual, + Not, + QueryFailedError, + Repository, +} from '@n8n/typeorm'; import { WorkflowStatistics } from '../entities'; import type { User } from '../entities'; @@ -125,7 +132,7 @@ export class WorkflowStatisticsRepository extends Repository role: 'workflow:owner', project: { projectRelations: { userId, role: { slug: PROJECT_OWNER_ROLE_SLUG } } }, }, - active: true, + activeVersionId: Not(IsNull()), }, name: StatisticsNames.productionSuccess, count: MoreThanOrEqual(5), diff --git a/packages/@n8n/db/src/repositories/workflow.repository.ts b/packages/@n8n/db/src/repositories/workflow.repository.ts index 1d34d5c5626..752d06583cb 100644 --- a/packages/@n8n/db/src/repositories/workflow.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow.repository.ts @@ -1,6 +1,6 @@ import { GlobalConfig } from '@n8n/config'; import { Service } from '@n8n/di'; -import { DataSource, Repository, In, Like } from '@n8n/typeorm'; +import { DataSource, Repository, In, Like, Not, IsNull } from '@n8n/typeorm'; import type { SelectQueryBuilder, UpdateResult, @@ -71,7 +71,7 @@ export class WorkflowRepository extends Repository { async getAllActiveIds() { const result = await this.find({ select: { id: true }, - where: { active: true }, + where: { activeVersionId: Not(IsNull()) }, relations: { shared: { project: { projectRelations: true } } }, }); @@ -81,7 +81,7 @@ export class WorkflowRepository extends Repository { async getActiveIds({ maxResults }: { maxResults?: number } = {}) { const activeWorkflows = await this.find({ select: ['id'], - where: { active: true }, + where: { activeVersionId: Not(IsNull()) }, // 'take' and 'order' are only needed when maxResults is provided: ...(maxResults ? { take: maxResults, order: { createdAt: 'ASC' } } : {}), }); @@ -90,14 +90,14 @@ export class WorkflowRepository extends Repository { async getActiveCount() { return await this.count({ - where: { active: true }, + where: { activeVersionId: Not(IsNull()) }, }); } async findById(workflowId: string) { return await this.findOne({ where: { id: workflowId }, - relations: { shared: { project: { projectRelations: true } } }, + relations: { shared: { project: { projectRelations: true } }, activeVersion: true }, }); } @@ -113,7 +113,7 @@ export class WorkflowRepository extends Repository { async getActiveTriggerCount() { const totalTriggerCount = await this.sum('triggerCount', { - active: true, + activeVersionId: Not(IsNull()), }); return totalTriggerCount ?? 0; } @@ -585,7 +585,11 @@ export class WorkflowRepository extends Repository { filter: ListQuery.Options['filter'], ): void { if (typeof filter?.active === 'boolean') { - qb.andWhere('workflow.active = :active', { active: filter.active }); + if (filter.active) { + qb.andWhere('workflow.activeVersionId IS NOT NULL'); + } else { + qb.andWhere('workflow.activeVersionId IS NULL'); + } } } @@ -686,6 +690,7 @@ export class WorkflowRepository extends Repository { 'workflow.createdAt', 'workflow.updatedAt', 'workflow.versionId', + 'workflow.activeVersionId', 'workflow.settings', 'workflow.description', ]); @@ -806,19 +811,42 @@ export class WorkflowRepository extends Repository { } async updateActiveState(workflowId: string, newState: boolean) { - return await this.update({ id: workflowId }, { active: newState }); + if (newState) { + return await this.createQueryBuilder() + .update(WorkflowEntity) + .set({ + activeVersionId: () => 'versionId', + active: true, + }) + .where('id = :workflowId', { workflowId }) + .execute(); + } else { + return await this.update({ id: workflowId }, { active: false, activeVersionId: null }); + } } async deactivateAll() { - return await this.update({ active: true }, { active: false }); + return await this.update( + { activeVersionId: Not(IsNull()) }, + { active: false, activeVersionId: null }, + ); } + // We're planning to remove this command in V2, so for now set activeVersion to the current version async activateAll() { - return await this.update({ active: false }, { active: true }); + await this.manager + .createQueryBuilder() + .update(WorkflowEntity) + .set({ + active: true, + activeVersionId: () => 'versionId', + }) + .where('activeVersionId IS NULL') + .execute(); } async findByActiveState(activeState: boolean) { - return await this.findBy({ active: activeState }); + return await this.findBy({ activeVersionId: activeState ? Not(IsNull()) : IsNull() }); } async moveAllToFolder(fromFolderId: string, toFolderId: string, tx: EntityManager) { @@ -854,12 +882,14 @@ export class WorkflowRepository extends Repository { ); const workflows: Array< - Pick & Partial> + Pick & + Partial> > = await qb .select([ 'workflow.id', 'workflow.name', 'workflow.active', + 'workflow.activeVersionId', ...(includeNodes ? ['workflow.nodes'] : []), ]) .where(whereClause, parameters) diff --git a/packages/@n8n/decorators/package.json b/packages/@n8n/decorators/package.json index 89cff5978ce..e45fc61d28c 100644 --- a/packages/@n8n/decorators/package.json +++ b/packages/@n8n/decorators/package.json @@ -12,6 +12,7 @@ "lint:fix": "eslint . --fix", "watch": "tsc -p tsconfig.build.json --watch", "test": "jest", + "test:unit": "jest", "test:dev": "jest --watch" }, "main": "dist/index.js", diff --git a/packages/@n8n/decorators/src/context-establishment/__tests__/context-establishment-hook.test.ts b/packages/@n8n/decorators/src/context-establishment/__tests__/context-establishment-hook.test.ts new file mode 100644 index 00000000000..7da6738799f --- /dev/null +++ b/packages/@n8n/decorators/src/context-establishment/__tests__/context-establishment-hook.test.ts @@ -0,0 +1,148 @@ +import { Container } from '@n8n/di'; + +import type { + ContextEstablishmentOptions, + ContextEstablishmentResult, + IContextEstablishmentHook, +} from '../context-establishment-hook'; +import { + ContextEstablishmentHookMetadata, + ContextEstablishmentHook, +} from '../context-establishment-hook-metadata'; + +describe('@ContextEstablishmentHook decorator', () => { + let hookMetadata: ContextEstablishmentHookMetadata; + + beforeEach(() => { + jest.resetAllMocks(); + + hookMetadata = new ContextEstablishmentHookMetadata(); + Container.set(ContextEstablishmentHookMetadata, hookMetadata); + }); + + it('should register hook in ContextEstablishmentHookMetadata', () => { + @ContextEstablishmentHook() + class TestHook implements IContextEstablishmentHook { + hookDescription = { name: 'test.hook' }; + async execute(_options: ContextEstablishmentOptions): Promise { + return {}; + } + isApplicableToTriggerNode(_nodeType: string): boolean { + return true; + } + } + + const registeredHooks = hookMetadata.getClasses(); + + expect(registeredHooks).toContain(TestHook); + expect(registeredHooks).toHaveLength(1); + }); + + it('should register multiple hooks', () => { + @ContextEstablishmentHook() + class FirstHook implements IContextEstablishmentHook { + hookDescription = { name: 'first.hook' }; + async execute(_options: ContextEstablishmentOptions): Promise { + return {}; + } + isApplicableToTriggerNode(_nodeType: string): boolean { + return true; + } + } + + @ContextEstablishmentHook() + class SecondHook implements IContextEstablishmentHook { + hookDescription = { name: 'second.hook' }; + async execute(_options: ContextEstablishmentOptions): Promise { + return {}; + } + isApplicableToTriggerNode(_nodeType: string): boolean { + return true; + } + } + + @ContextEstablishmentHook() + class ThirdHook implements IContextEstablishmentHook { + hookDescription = { name: 'third.hook' }; + async execute(_options: ContextEstablishmentOptions): Promise { + return {}; + } + isApplicableToTriggerNode(_nodeType: string): boolean { + return true; + } + } + + const registeredHooks = hookMetadata.getClasses(); + + expect(registeredHooks).toContain(FirstHook); + expect(registeredHooks).toContain(SecondHook); + expect(registeredHooks).toContain(ThirdHook); + expect(registeredHooks).toHaveLength(3); + }); + + it('should apply Service decorator', () => { + @ContextEstablishmentHook() + class TestHook implements IContextEstablishmentHook { + hookDescription = { name: 'test.hook' }; + async execute(_options: ContextEstablishmentOptions): Promise { + return {}; + } + isApplicableToTriggerNode(_nodeType: string): boolean { + return true; + } + } + + expect(Container.has(TestHook)).toBe(true); + }); + + it('should allow instantiation of registered hooks with accessible hookDescription', () => { + @ContextEstablishmentHook() + class TestHook implements IContextEstablishmentHook { + hookDescription = { name: 'credentials.bearerToken' }; + async execute(_options: ContextEstablishmentOptions): Promise { + return {}; + } + isApplicableToTriggerNode(_nodeType: string): boolean { + return true; + } + } + + const hookInstance = Container.get(TestHook); + + expect(hookInstance).toBeInstanceOf(TestHook); + expect(hookInstance.hookDescription).toEqual({ name: 'credentials.bearerToken' }); + expect(hookInstance.hookDescription.name).toBe('credentials.bearerToken'); + }); + + it('should register hooks with different description names', () => { + @ContextEstablishmentHook() + class BearerTokenHook implements IContextEstablishmentHook { + hookDescription = { name: 'credentials.bearerToken' }; + async execute(_options: ContextEstablishmentOptions): Promise { + return {}; + } + isApplicableToTriggerNode(_nodeType: string): boolean { + return true; + } + } + + @ContextEstablishmentHook() + class ApiKeyHook implements IContextEstablishmentHook { + hookDescription = { name: 'credentials.apiKey' }; + async execute(_options: ContextEstablishmentOptions): Promise { + return {}; + } + isApplicableToTriggerNode(_nodeType: string): boolean { + return true; + } + } + + const registeredHooks = hookMetadata.getClasses(); + const bearerTokenHook = Container.get(BearerTokenHook); + const apiKeyHook = Container.get(ApiKeyHook); + + expect(registeredHooks).toHaveLength(2); + expect(bearerTokenHook.hookDescription.name).toBe('credentials.bearerToken'); + expect(apiKeyHook.hookDescription.name).toBe('credentials.apiKey'); + }); +}); diff --git a/packages/@n8n/decorators/src/context-establishment/context-establishment-hook-metadata.ts b/packages/@n8n/decorators/src/context-establishment/context-establishment-hook-metadata.ts new file mode 100644 index 00000000000..e26dfb1e749 --- /dev/null +++ b/packages/@n8n/decorators/src/context-establishment/context-establishment-hook-metadata.ts @@ -0,0 +1,200 @@ +import { Container, Service } from '@n8n/di'; + +import { ContextEstablishmentHookClass } from './context-establishment-hook'; + +/** + * Registry entry for a context establishment hook. + * + * This is a lightweight wrapper around the hook class constructor that can be + * extended in the future to include additional metadata if needed (e.g., module + * source, registration timestamp, feature flags, license flags). + * + * @internal + */ +type ContextEstablishmentHookEntry = { + /** The hook class constructor for DI container instantiation */ + class: ContextEstablishmentHookClass; +}; + +/** + * Low-level metadata registry for context establishment hooks. + * + * This service acts as a simple collection of registered hook classes that gets + * populated automatically by the @ContextEstablishmentHook decorator at module + * load time. It serves as the foundation for the higher-level Hook Registry. + * + * **Architecture:** + * ``` + * Decorator → ContextEstablishmentHookMetadata → Hook Registry → Execution Engine + * (registration) (collection) (discovery) (execution) + * ``` + * + * @see ContextEstablishmentHook decorator for automatic registration + * @see IContextEstablishmentHook for hook interface + */ +@Service() +export class ContextEstablishmentHookMetadata { + /** + * Internal collection of registered hook classes. + * + * Uses Set for efficient deduplication (though duplicate registration + * should not occur with proper decorator usage). + */ + private readonly contextEstablishmentHooks: Set = new Set(); + + /** + * Registers a hook class in the metadata collection. + * + * Called automatically by the @ContextEstablishmentHook decorator during + * module loading. Should not be called directly by application code. + * + * **Note:** This method does not validate uniqueness or check for naming + * conflicts. Validation happens later in the Hook Registry. + * + * @param hookEntry - The hook class entry to register + * + * @internal Called by decorator only + */ + register(hookEntry: ContextEstablishmentHookEntry) { + this.contextEstablishmentHooks.add(hookEntry); + } + + /** + * Retrieves all registered hook entries. + * + * Returns an array of [index, entry] tuples compatible with Set.entries(). + * Primarily used for debugging or low-level iteration. + * + * **Prefer getClasses()** for most use cases as it returns just the classes. + * + * @returns Array of [index, entry] tuples from the internal Set + * + * @example + * ```typescript + * const entries = metadata.getEntries(); + * for (const [index, entry] of entries) { + * console.log(`Hook ${index}:`, entry.class.name); + * } + * ``` + */ + getEntries() { + return [...this.contextEstablishmentHooks.entries()]; + } + + /** + * Retrieves all registered hook classes. + * + * This is the primary method used by the Hook Registry to obtain hook classes + * for instantiation and indexing. Returns just the class constructors without + * the wrapper entry objects. + * + * **Usage pattern:** + * ```typescript + * const classes = metadata.getClasses(); + * const hooks = classes.map(HookClass => Container.get(HookClass)); + * const hooksByName = new Map(hooks.map(h => [h.hookDescription.name, h])); + * ``` + * + * @returns Array of hook class constructors ready for DI instantiation + * + * @example + * ```typescript + * @Service() + * export class HookRegistry { + * constructor( + * private metadata: ContextEstablishmentHookMetadata, + * private container: Container + * ) { + * const hookClasses = metadata.getClasses(); + * this.hooks = hookClasses.map(cls => container.get(cls)); + * } + * } + * ``` + */ + getClasses() { + return [...this.contextEstablishmentHooks.values()].map((entry) => entry.class); + } +} + +/** + * Class decorator for context establishment hooks. + * + * This decorator performs two critical functions: + * 1. **Registers** the hook class in ContextEstablishmentHookMetadata for discovery + * 2. **Enables DI** by applying @Service() to make the hook injectable + * + * The decorator executes at module load time (when the class is defined), ensuring + * all hooks are registered before the application starts. This enables automatic + * discovery without manual registration code. + * + * **Registration flow:** + * ``` + * @ContextEstablishmentHook() // 1. Decorator executes + * export class BearerTokenHook // 2. Class is defined + * ↓ + * ContextEstablishmentHookMetadata // 3. Hook class registered in metadata + * ↓ + * @Service() // 4. DI container registration + * ↓ + * Hook is discoverable & injectable // 5. Ready for use + * ``` + * + * **Design pattern:** + * This follows the declarative registration pattern used throughout n8n for + * extensibility (similar to node registration). Hooks self-register without + * requiring central registration files or manual imports. + * + * **Requirements:** + * - Decorated class MUST implement IContextEstablishmentHook + * - Decorated class MUST have a hookDescription property with unique name + * + * **Important notes:** + * - No decorator parameters needed (hook metadata lives on hook instance) + * - Hooks are registered as singletons via @Service() + * - Registration happens eagerly at module load, not lazily + * - Duplicate decoration of the same class is safe (Set deduplicates) + * + * @see IContextEstablishmentHook for interface requirements + * @see ContextEstablishmentHookMetadata for underlying registry + * @see HookDescription for hook metadata structure + * + * @example + * ```typescript + * // Basic hook registration: + * @ContextEstablishmentHook() + * export class BearerTokenHook implements IContextEstablishmentHook { + * hookDescription = { + * name: 'credentials.bearerToken' + * }; + * + * async execute(options: ContextEstablishmentOptions) { + * // Extract bearer token from Authorization header + * const token = this.extractToken(options.triggerItem); + * return { + * triggerItem: this.removeAuthHeader(options.triggerItem), + * contextUpdate: { + * credentials: { version: 1, identity: token } + * } + * }; + * } + * + * isApplicableToTriggerNode(nodeType: string) { + * return nodeType === 'n8n-nodes-base.webhook'; + * } + * } + * ``` + * + * @returns A class decorator function that registers and enables DI for the hook + */ +export const ContextEstablishmentHook = + () => + (target: T) => { + // Register hook class in metadata for discovery by Hook Registry + Container.get(ContextEstablishmentHookMetadata).register({ + class: target, + }); + + // Enable dependency injection for the hook class + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Service()(target); + }; diff --git a/packages/@n8n/decorators/src/context-establishment/context-establishment-hook.ts b/packages/@n8n/decorators/src/context-establishment/context-establishment-hook.ts new file mode 100644 index 00000000000..15efaa1ef87 --- /dev/null +++ b/packages/@n8n/decorators/src/context-establishment/context-establishment-hook.ts @@ -0,0 +1,328 @@ +import type { Constructable } from '@n8n/di'; +import type { + INode, + INodeExecutionData, + PlaintextExecutionContext, + IWorkflowBase, +} from 'n8n-workflow'; + +/** + * Input parameters passed to a context establishment hook during execution. + * + * Hooks receive the current workflow state and extract information from + * trigger items to build the execution context (e.g., credentials, environment). + * All hooks work with plaintext (decrypted) context for runtime operations. + * + * @see IContextEstablishmentHook + * @see PlaintextExecutionContext + */ +export type ContextEstablishmentOptions = { + /** The trigger node that initiated the workflow execution */ + triggerNode: INode; + + /** The complete workflow definition */ + workflow: IWorkflowBase; + + /** + * Trigger items from the workflow execution start. + * This array represents items as modified by previous hooks in the chain. + * Hooks can extract data from these items and optionally modify them + * (e.g., removing sensitive headers before storage). + */ + triggerItems: INodeExecutionData[]; + + /** + * The plaintext execution context built so far. + * Includes base context plus results from any previously executed hooks. + * Contains decrypted credential data for runtime operations. + * + * @see PlaintextExecutionContext for security considerations + */ + context: PlaintextExecutionContext; + + /** + * Hook-specific configuration provided by the trigger node. + * Structure varies per hook type (e.g., { removeFromItem: true } for bearer token hook). + */ + options?: Record; +}; + +/** + * Result returned by a context establishment hook after execution. + * + * Hooks can modify trigger items (e.g., remove sensitive headers) and + * contribute partial context updates that get merged into the execution context. + * All context data is in plaintext form during hook execution. + * + * @see IContextEstablishmentHook + * @see PlaintextExecutionContext + */ +export type ContextEstablishmentResult = { + /** + * The potentially modified trigger items. + * If undefined, the original trigger items are preserved unchanged. + * + * Common use case: Removing sensitive data (e.g., Authorization headers) + * before storing items in execution history. + * + * @example + * ```typescript + * // Remove Authorization header from trigger items + * const modifiedItems = options.triggerItems.map(item => ({ + * ...item, + * json: { + * ...item.json, + * headers: { + * ...item.json.headers, + * authorization: undefined + * } + * } + * })); + * return { triggerItems: modifiedItems, contextUpdate: { ... } }; + * ``` + */ + triggerItems?: INodeExecutionData[]; + + /** + * Partial context update to merge into the execution context. + * If undefined, no context updates are applied. + * + * Contains only this hook's contributions (e.g., credentials data). + * Multiple hooks' updates are merged sequentially during execution. + * Context data is in plaintext form and will be encrypted before persistence. + * + * @example + * ```typescript + * // Add credential context from bearer token + * return { + * triggerItems: modifiedItems, + * contextUpdate: { + * credentials: { + * version: 1, + * identity: extractedToken, + * metadata: { source: 'bearer-token' } + * } + * } + * }; + * ``` + */ + contextUpdate?: Partial; +}; + +/** + * Metadata describing a context establishment hook. + * + * This object carries self-describing information about the hook that enables + * runtime discovery, lookup, and instantiation. Each hook instance serves as + * the single source of truth for its own metadata. + * + * **Design rationale:** + * - Hook instances are self-describing (no external configuration files) + * - Name lookup happens at runtime via Registry, not during registration + * - Description can be extended without changing decorator or registry internals + * - Supports future features like versioning, schema validation, and categorization + * + * **Future extensions** may include: + * - `version?: string` - Semantic version of hook implementation for compatibility checks + * - `configSchema?: ZodSchema` - Validation schema for hook-specific options + * - `tags?: string[]` - Categorization tags for grouping and filtering + * - `applicableTriggers?: string[]` - Cached list of compatible trigger node types + * - `deprecated?: boolean | string` - Deprecation status and migration guidance + * + * @see IContextEstablishmentHook.hookDescription + * @see ContextEstablishmentHookMetadata for registration mechanism + * + * @example + * ```typescript + * @ContextEstablishmentHook() + * export class BearerTokenHook implements IContextEstablishmentHook { + * hookDescription = { + * name: 'credentials.bearerToken' + * }; + * + * // ... hook implementation + * } + * + * ``` + */ +export type HookDescription = { + /** + * Unique identifier for this hook type. + * + * Used by the Hook Registry (to be implemented) to index and retrieve + * hook instances at runtime. Must be unique across all registered hooks. + * + * **Naming convention**: Use namespaced names like 'credentials.bearerToken' + * or 'envVars.tenantConfig' to organize hooks by domain and avoid collisions. + * + * **Usage contexts:** + * - Trigger node configuration specifies hooks by name + * - Hook Registry uses name as lookup key + * - UI displays localized names via i18n (e.g., `hooks.${name}.displayName`) + * - Logging and debugging references hooks by name + * - Error messages include hook name for troubleshooting + * + * **Versioning**: Future hook versions can use naming like 'credentials.bearerToken.v2' + * if breaking changes are needed, though this is not required initially. + * + * @example 'credentials.bearerToken' + * @example 'credentials.apiKey' + * @example 'envVars.tenantConfig' + * @example 'audit.requestMetadata' + */ + name: string; +}; + +/** + * Interface for context establishment hooks that extract data from trigger + * items and extend the execution context during workflow initialization. + * + * @see ContextEstablishmentOptions - Input parameters + * @see ContextEstablishmentResult - Output structure + * @see PlaintextExecutionContext - Runtime context type with decrypted data + */ +export interface IContextEstablishmentHook { + /** + * Self-describing metadata for this hook instance. + * + * Provides the unique name and future metadata used by the Hook Registry + * for discovery, lookup, and validation. This property makes each hook + * instance self-contained and discoverable without external configuration. + * + * @see HookDescription for detailed metadata structure and future extensions + * + * @example + * ```typescript + * @ContextEstablishmentHook() + * export class BearerTokenHook implements IContextEstablishmentHook { + * hookDescription = { + * name: 'credentials.bearerToken' + * }; + * + * async execute(options: ContextEstablishmentOptions) { + * // Hook implementation + * } + * + * isApplicableToTriggerNode(nodeType: string) { + * return nodeType === 'n8n-nodes-base.webhook'; + * } + * } + * ``` + */ + hookDescription: HookDescription; + /** + * Executes the hook to extract context data from trigger information. + * + * **Implementation requirements:** + * 1. Extract relevant data from trigger items (headers, body, query params, etc.) + * 2. Optionally modify trigger items to remove sensitive data + * 3. Return partial context updates to merge into execution context + * 4. Throw errors for unrecoverable failures (stops workflow execution) + * + * **Execution order:** + * Hooks execute sequentially in the order configured by the trigger node. + * Each hook receives: + * - Trigger items as modified by all previous hooks + * - Context with updates from all previous hooks + * + * **Context handling:** + * - Input context is plaintext (PlaintextExecutionContext) for runtime operations + * - Output updates are plaintext and will be encrypted before persistence + * - Never log or expose plaintext context outside hook execution + * + * **Error handling:** + * - Throw errors if required data is missing (e.g., expected header not found) + * - Use descriptive error messages for debugging + * - Errors stop workflow execution (fail-fast approach) + * + * @param options - Input parameters including trigger node, workflow, items, and current context + * @returns Promise resolving to modified trigger items and context updates + * @throws Error if hook execution fails (stops workflow execution) + * + * @example + * ```typescript + * async execute(options: ContextEstablishmentOptions): Promise { + * const removeHeader = options.options?.removeFromItem ?? true; + * + * // Extract data + * const token = this.extractToken(options.triggerItems); + * if (!token) { + * throw new Error('Bearer token not found in Authorization header'); + * } + * + * // Optionally modify items + * const modifiedItems = removeHeader + * ? this.removeAuthHeader(options.triggerItems) + * : undefined; + * + * // Return context update + * return { + * triggerItems: modifiedItems, + * contextUpdate: { + * credentials: { version: 1, identity: token } + * } + * }; + * } + * ``` + */ + execute(options: ContextEstablishmentOptions): Promise; + + /** + * Method to determine if this hook is applicable to a specific trigger node type. + * + * **Use cases:** + * - **UI filtering**: Show only relevant hooks for a trigger type in node configuration + * - **Validation**: Prevent incompatible hook configurations at save time + * - **Auto-suggestion**: Suggest applicable hooks based on trigger node selection + * - **Documentation**: Generate trigger-specific hook documentation + * + * **Implementation notes:** + * - Return true if the hook can extract meaningful data from this trigger type + * - Consider transport layer (HTTP, AMQP, manual, etc.) + * - Multiple triggers can share the same hook (e.g., webhook and form trigger both support bearer tokens) + * + * @param nodeType - The node type identifier (e.g., 'n8n-nodes-base.webhook') + * @returns true if this hook can be used with the given trigger node type + * + * @example + * ```typescript + * // Hook only works with HTTP-based triggers + * isApplicableToTriggerNode(nodeType: string): boolean { + * return [ + * 'n8n-nodes-base.webhook', + * 'n8n-nodes-base.formTrigger', + * 'n8n-nodes-base.httpRequest' + * ].includes(nodeType); + * } + * ``` + * + * @example + * ```typescript + * // Hook works with any trigger that has HTTP headers + * isApplicableToTriggerNode(nodeType: string): boolean { + * return nodeType.includes('webhook') || nodeType.includes('http'); + * } + * ``` + */ + isApplicableToTriggerNode(nodeType: string): boolean; +} + +/** + * Type representing the constructor/class of a context establishment hook. + * + * Used by the dependency injection container to register and instantiate + * hook classes at runtime. Works with the @ContextEstablishmentHook decorator. + * + * @see IContextEstablishmentHook + * @see ContextEstablishmentHook decorator in './index.ts' + * + * @example + * ```typescript + * import { Container } from '@n8n/di'; + * import type { ContextEstablishmentHookClass } from './context-establishment-hook'; + * + * const HookClass: ContextEstablishmentHookClass = BearerTokenHook; + * const hookInstance = Container.get(HookClass); + * ``` + */ +export type ContextEstablishmentHookClass = Constructable; diff --git a/packages/@n8n/decorators/src/context-establishment/index.ts b/packages/@n8n/decorators/src/context-establishment/index.ts new file mode 100644 index 00000000000..7ea4a597362 --- /dev/null +++ b/packages/@n8n/decorators/src/context-establishment/index.ts @@ -0,0 +1,5 @@ +export { + ContextEstablishmentHookMetadata, + ContextEstablishmentHook, +} from './context-establishment-hook-metadata'; +export type * from './context-establishment-hook'; diff --git a/packages/@n8n/di/package.json b/packages/@n8n/di/package.json index aa1da4d78e9..680fe15d486 100644 --- a/packages/@n8n/di/package.json +++ b/packages/@n8n/di/package.json @@ -12,6 +12,7 @@ "lint:fix": "eslint . --fix", "watch": "tsc -p tsconfig.build.json --watch", "test": "jest", + "test:unit": "jest", "test:dev": "jest --watch" }, "main": "dist/di.js", diff --git a/packages/@n8n/eslint-config/package.json b/packages/@n8n/eslint-config/package.json index c8439b6ed98..4aa0a2d0787 100644 --- a/packages/@n8n/eslint-config/package.json +++ b/packages/@n8n/eslint-config/package.json @@ -24,6 +24,7 @@ "format": "biome format --write .", "format:check": "biome ci .", "test": "vitest run", + "test:unit": "vitest run", "test:dev": "vitest", "typecheck": "tsc --noEmit", "watch": "tsc --watch" diff --git a/packages/@n8n/eslint-plugin-community-nodes/package.json b/packages/@n8n/eslint-plugin-community-nodes/package.json index c42355b7a37..5c288e456f1 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/package.json +++ b/packages/@n8n/eslint-plugin-community-nodes/package.json @@ -21,6 +21,7 @@ "lint:fix": "eslint src --fix", "lint:docs": "eslint-doc-generator --check", "test": "vitest run", + "test:unit": "vitest run", "test:dev": "vitest", "typecheck": "tsc --noEmit", "watch": "tsc --watch --project tsconfig.build.json" diff --git a/packages/@n8n/imap/package.json b/packages/@n8n/imap/package.json index fb0fad7f09a..af08ce99876 100644 --- a/packages/@n8n/imap/package.json +++ b/packages/@n8n/imap/package.json @@ -12,6 +12,7 @@ "lint:fix": "eslint . --fix", "watch": "tsc -p tsconfig.build.json --watch", "test": "vitest run", + "test:unit": "vitest run", "test:dev": "vitest --silent=false" }, "main": "dist/index.js", diff --git a/packages/@n8n/json-schema-to-zod/package.json b/packages/@n8n/json-schema-to-zod/package.json index 099b6946816..2111b093ff1 100644 --- a/packages/@n8n/json-schema-to-zod/package.json +++ b/packages/@n8n/json-schema-to-zod/package.json @@ -29,6 +29,7 @@ "build": "rimraf ./dist && pnpm run build:types && pnpm run build:cjs && pnpm run build:esm", "dry": "pnpm run build && pnpm pub --dry-run", "test": "jest", + "test:unit": "jest", "test:watch": "jest --watch" }, "keywords": [ diff --git a/packages/@n8n/node-cli/package.json b/packages/@n8n/node-cli/package.json index 566e9e69e48..c2706a9e732 100644 --- a/packages/@n8n/node-cli/package.json +++ b/packages/@n8n/node-cli/package.json @@ -27,6 +27,7 @@ "build": "tsc -p tsconfig.build.json && pnpm copy-templates", "publish:dry": "pnpm run build && pnpm pub --dry-run", "test": "vitest run", + "test:unit": "vitest run", "test:dev": "vitest --silent=false", "start": "./bin/n8n-node.mjs" }, diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V3/helpers/tests/prepareItemContext.test.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V3/helpers/tests/prepareItemContext.test.ts index 8f96487381c..0ebcb154e1c 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V3/helpers/tests/prepareItemContext.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V3/helpers/tests/prepareItemContext.test.ts @@ -1,13 +1,12 @@ import type { ChatPromptTemplate } from '@langchain/core/prompts'; import { mock } from 'jest-mock-extended'; import type { Tool } from 'langchain/tools'; -import type { IExecuteFunctions, INode, EngineResponse } from 'n8n-workflow'; +import type { IExecuteFunctions, INode } from 'n8n-workflow'; import * as helpers from '@utils/helpers'; import * as outputParsers from '@utils/output_parsers/N8nOutputParser'; import * as commonHelpers from '../../../common'; -import type { RequestResponseMetadata } from '../../types'; import { prepareItemContext } from '../prepareItemContext'; jest.mock('@utils/helpers', () => ({ @@ -33,15 +32,12 @@ beforeEach(() => { }); describe('processItem', () => { - it('should return null when item was already processed', async () => { - const response: EngineResponse = { - actionResponses: [], - metadata: { itemIndex: 0, previousRequests: [] }, - }; + it('should throw error when text parameter is empty', async () => { + jest.spyOn(helpers, 'getPromptInputByType').mockReturnValue(undefined as any); - const result = await prepareItemContext(mockContext, 0, response); - - expect(result).toBeNull(); + await expect(prepareItemContext(mockContext, 0)).rejects.toThrow( + 'The "text" parameter is empty.', + ); }); it('should process item and return context', async () => { diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/common.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/common.ts index e6f19bca017..5f43ba958fc 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/common.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/common.ts @@ -34,13 +34,31 @@ export function getOutputParserSchema( /* ----------------------------------------------------------- Binary Data Helpers ----------------------------------------------------------- */ +function isTextFile(mimeType: string): boolean { + return ( + mimeType.startsWith('text/') || + mimeType === 'application/json' || + mimeType === 'application/xml' || + mimeType === 'application/csv' || + mimeType === 'application/x-yaml' || + mimeType === 'application/yaml' + ); +} + +function isImageFile(mimeType: string): boolean { + return mimeType.startsWith('image/'); +} + /** - * Extracts binary image messages from the input data. + * Extracts binary messages (images and text files) from the input data. * When operating in filesystem mode, the binary stream is first converted to a buffer. * + * Images are converted to base64 data URLs. + * Text files are read as UTF-8 text and included in the message content. + * * @param ctx - The execution context * @param itemIndex - The current item index - * @returns A HumanMessage containing the binary image messages. + * @returns A HumanMessage containing the binary messages (images and text files). */ export async function extractBinaryMessages( ctx: IExecuteFunctions | ISupplyDataFunctions, @@ -49,30 +67,58 @@ export async function extractBinaryMessages( const binaryData = ctx.getInputData()?.[itemIndex]?.binary ?? {}; const binaryMessages = await Promise.all( Object.values(binaryData) - .filter((data) => data.mimeType.startsWith('image/')) + // select only the files we can process + .filter((data) => isImageFile(data.mimeType) || isTextFile(data.mimeType)) .map(async (data) => { - let binaryUrlString: string; + // Handle images + if (isImageFile(data.mimeType)) { + let binaryUrlString: string; - // In filesystem mode we need to get binary stream by id before converting it to buffer - if (data.id) { - const binaryBuffer = await ctx.helpers.binaryToBuffer( - await ctx.helpers.getBinaryStream(data.id), - ); - binaryUrlString = `data:${data.mimeType};base64,${Buffer.from(binaryBuffer).toString( - BINARY_ENCODING, - )}`; - } else { - binaryUrlString = data.data.includes('base64') - ? data.data - : `data:${data.mimeType};base64,${data.data}`; + // In filesystem mode we need to get binary stream by id before converting it to buffer + if (data.id) { + const binaryBuffer = await ctx.helpers.binaryToBuffer( + await ctx.helpers.getBinaryStream(data.id), + ); + binaryUrlString = `data:${data.mimeType};base64,${Buffer.from(binaryBuffer).toString( + BINARY_ENCODING, + )}`; + } else { + binaryUrlString = data.data.includes('base64') + ? data.data + : `data:${data.mimeType};base64,${data.data}`; + } + + return { + type: 'image_url', + image_url: { + url: binaryUrlString, + }, + }; } + // Handle text files + else { + let textContent: string; + if (data.id) { + const binaryBuffer = await ctx.helpers.binaryToBuffer( + await ctx.helpers.getBinaryStream(data.id), + ); + textContent = binaryBuffer.toString('utf-8'); + } else { + // Data might be base64 encoded with or without data URL prefix + if (data.data.includes('base64,')) { + const base64Data = data.data.split('base64,')[1]; + textContent = Buffer.from(base64Data, 'base64').toString('utf-8'); + } else { + // Default: binary data is base64-encoded without prefix + textContent = Buffer.from(data.data, 'base64').toString('utf-8'); + } + } - return { - type: 'image_url', - image_url: { - url: binaryUrlString, - }, - }; + return { + type: 'text', + text: `File: ${data.fileName ?? 'attachment'}\nContent:\n${textContent}`, + }; + } }), ); return new HumanMessage({ diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/test/ToolsAgent/commons.test.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/test/ToolsAgent/commons.test.ts index b0bb98fbaea..f953f45e24d 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/test/ToolsAgent/commons.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/test/ToolsAgent/commons.test.ts @@ -116,6 +116,97 @@ describe('extractBinaryMessages', () => { image_url: { url: expectedUrl }, }); }); + + it('should extract markdown and CSV text files', async () => { + const mdContent = '# Test Markdown\n\nThis is a test.'; + const csvContent = 'name,age\nJohn,30'; + const fakeItem = { + json: {}, + binary: { + markdown: { + mimeType: 'text/markdown', + fileName: 'test.md', + data: `data:text/markdown;base64,${Buffer.from(mdContent).toString('base64')}`, + }, + csv: { + mimeType: 'text/csv', + fileName: 'data.csv', + data: `data:text/csv;base64,${Buffer.from(csvContent).toString('base64')}`, + }, + }, + }; + mockContext.getInputData.mockReturnValue([fakeItem]); + + const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0); + + expect(Array.isArray(humanMsg.content)).toBe(true); + expect(humanMsg.content).toHaveLength(2); + expect(humanMsg.content).toEqual( + expect.arrayContaining([ + { type: 'text', text: `File: test.md\nContent:\n${mdContent}` }, + { type: 'text', text: `File: data.csv\nContent:\n${csvContent}` }, + ]), + ); + }); + + it('should extract both images and text files together', async () => { + const textContent = 'Some text content'; + const fakeItem = { + json: {}, + binary: { + image: { + mimeType: 'image/png', + fileName: 'test.png', + data: 'imageData123', + }, + text: { + mimeType: 'text/plain', + fileName: 'test.txt', + data: `data:text/plain;base64,${Buffer.from(textContent).toString('base64')}`, + }, + }, + }; + mockContext.getInputData.mockReturnValue([fakeItem]); + + const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0); + + expect(Array.isArray(humanMsg.content)).toBe(true); + expect(humanMsg.content).toHaveLength(2); + expect(humanMsg.content).toEqual( + expect.arrayContaining([ + { + type: 'image_url', + image_url: { url: 'data:image/png;base64,imageData123' }, + }, + { type: 'text', text: `File: test.txt\nContent:\n${textContent}` }, + ]), + ); + }); + + it('should decode base64-encoded text files without prefix', async () => { + const textContent = 'Hello world!'; + const fakeItem = { + json: {}, + binary: { + text: { + mimeType: 'text/plain', + fileName: 'test.txt', + // Default n8n binary format: base64 without data URL prefix + data: Buffer.from(textContent).toString('base64'), + }, + }, + }; + mockContext.getInputData.mockReturnValue([fakeItem]); + + const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0); + + expect(Array.isArray(humanMsg.content)).toBe(true); + expect(humanMsg.content).toHaveLength(1); + expect(humanMsg.content[0]).toEqual({ + type: 'text', + text: `File: test.txt\nContent:\n${textContent}`, + }); + }); }); describe('fixEmptyContentMessage', () => { diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/__test__/McpClientTool.node.test.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/__test__/McpClientTool.node.test.ts index b97e2dbab84..8a413e5d168 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/__test__/McpClientTool.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/__test__/McpClientTool.node.test.ts @@ -229,6 +229,7 @@ describe('McpClientTool', () => { expect(SSEClientTransport).toHaveBeenCalledTimes(1); expect(SSEClientTransport).toHaveBeenCalledWith(url, { eventSourceInit: { fetch: expect.any(Function) }, + fetch: expect.any(Function), requestInit: { headers: { 'my-header': 'header-value' } }, }); @@ -278,6 +279,7 @@ describe('McpClientTool', () => { expect(SSEClientTransport).toHaveBeenCalledTimes(1); expect(SSEClientTransport).toHaveBeenCalledWith(url, { eventSourceInit: { fetch: expect.any(Function) }, + fetch: expect.any(Function), requestInit: { headers: { Authorization: 'Bearer my-token' } }, }); diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 066de9f5f00..00c1bef9174 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -16,6 +16,7 @@ "lint:fix": "eslint nodes credentials utils --fix", "watch": "tsup --watch nodes --watch credentials --watch utils --watch types --tsconfig tsconfig.build.json --onSuccess \"node ./scripts/post-build.js\"", "test": "jest", + "test:unit": "jest", "test:dev": "jest --watch" }, "files": [ diff --git a/packages/@n8n/permissions/package.json b/packages/@n8n/permissions/package.json index 291cbca68fc..a983c39d3b1 100644 --- a/packages/@n8n/permissions/package.json +++ b/packages/@n8n/permissions/package.json @@ -12,6 +12,7 @@ "lint:fix": "eslint . --fix", "watch": "tsc -p tsconfig.build.json --watch", "test": "jest", + "test:unit": "jest", "test:dev": "jest --watch" }, "main": "dist/index.js", diff --git a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap index 3fc5542c24c..31bfd80d0de 100644 --- a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap +++ b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap @@ -143,6 +143,7 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = ` "role:manage", "role:*", "mcp:manage", + "mcp:oauth", "mcp:*", "mcpApiKey:create", "mcpApiKey:rotate", diff --git a/packages/@n8n/stylelint-config/package.json b/packages/@n8n/stylelint-config/package.json index 1b04ffd634e..925ae11d69c 100644 --- a/packages/@n8n/stylelint-config/package.json +++ b/packages/@n8n/stylelint-config/package.json @@ -20,6 +20,7 @@ "format": "biome format --write .", "format:check": "biome ci .", "test": "jest", + "test:unit": "jest", "test:dev": "jest --watch", "typecheck": "tsc --noEmit", "watch": "tsc --watch" diff --git a/packages/@n8n/task-runner/package.json b/packages/@n8n/task-runner/package.json index c05b121b0c2..72dbb84a04d 100644 --- a/packages/@n8n/task-runner/package.json +++ b/packages/@n8n/task-runner/package.json @@ -10,6 +10,7 @@ "format": "biome format --write src", "format:check": "biome ci src", "test": "jest", + "test:unit": "jest", "test:watch": "jest --watch", "lint": "eslint . --quiet", "lint:fix": "eslint . --fix", diff --git a/packages/@n8n/utils/package.json b/packages/@n8n/utils/package.json index 96b9a5987c2..01cc39936bc 100644 --- a/packages/@n8n/utils/package.json +++ b/packages/@n8n/utils/package.json @@ -31,6 +31,7 @@ "preview": "vite preview", "typecheck": "tsc --noEmit", "test": "vitest run", + "test:unit": "vitest run", "test:dev": "vitest --silent=false", "lint": "eslint src --quiet", "lint:fix": "eslint src --fix", diff --git a/packages/frontend/editor-ui/src/app/utils/fileUtils.test.ts b/packages/@n8n/utils/src/files/sanitize.test.ts similarity index 98% rename from packages/frontend/editor-ui/src/app/utils/fileUtils.test.ts rename to packages/@n8n/utils/src/files/sanitize.test.ts index 52bef112379..dd7ac3f26cc 100644 --- a/packages/frontend/editor-ui/src/app/utils/fileUtils.test.ts +++ b/packages/@n8n/utils/src/files/sanitize.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { sanitizeFilename } from './fileUtils'; + +import { sanitizeFilename } from './sanitize'; describe('sanitizeFilename', () => { it('should return normal filenames unchanged', () => { diff --git a/packages/@n8n/utils/src/files/sanitize.ts b/packages/@n8n/utils/src/files/sanitize.ts new file mode 100644 index 00000000000..29aae08960e --- /dev/null +++ b/packages/@n8n/utils/src/files/sanitize.ts @@ -0,0 +1,87 @@ +// Constants definition +/* eslint-disable no-control-regex */ +const INVALID_CHARS_REGEX = /[<>:"/\\|?*\u0000-\u001F\u007F-\u009F]/g; +const ZERO_WIDTH_CHARS_REGEX = /[\u200B-\u200D\u2060\uFEFF]/g; +const UNICODE_SPACES_REGEX = /[\u00A0\u2000-\u200A]/g; +const LEADING_TRAILING_DOTS_SPACES_REGEX = /^[\s.]+|[\s.]+$/g; +/* eslint-enable no-control-regex */ + +const WINDOWS_RESERVED_NAMES = new Set([ + 'CON', + 'PRN', + 'AUX', + 'NUL', + 'COM1', + 'COM2', + 'COM3', + 'COM4', + 'COM5', + 'COM6', + 'COM7', + 'COM8', + 'COM9', + 'LPT1', + 'LPT2', + 'LPT3', + 'LPT4', + 'LPT5', + 'LPT6', + 'LPT7', + 'LPT8', + 'LPT9', +]); + +const DEFAULT_FALLBACK_NAME = 'untitled'; +const MAX_FILENAME_LENGTH = 200; + +/** + * Sanitizes a filename to be compatible with Mac, Linux, and Windows file systems + * + * Main features: + * - Replace invalid characters (e.g. ":" in hello:world) + * - Handle Windows reserved names + * - Limit filename length + * - Normalize Unicode characters + * + * @param filename - The filename to sanitize (without extension) + * @param maxLength - Maximum filename length (default: 200) + * @returns A sanitized filename (without extension) + * + * @example + * sanitizeFilename('hello:world') // returns 'hello_world' + * sanitizeFilename('CON') // returns '_CON' + * sanitizeFilename('') // returns 'untitled' + */ +export const sanitizeFilename = ( + filename: string, + maxLength: number = MAX_FILENAME_LENGTH, +): string => { + // Input validation + if (!filename) { + return DEFAULT_FALLBACK_NAME; + } + + let baseName = filename + .trim() + .replace(INVALID_CHARS_REGEX, '_') + .replace(ZERO_WIDTH_CHARS_REGEX, '') + .replace(UNICODE_SPACES_REGEX, ' ') + .replace(LEADING_TRAILING_DOTS_SPACES_REGEX, ''); + + // Handle empty or invalid filenames after cleaning + if (!baseName) { + baseName = DEFAULT_FALLBACK_NAME; + } + + // Handle Windows reserved names + if (WINDOWS_RESERVED_NAMES.has(baseName.toUpperCase())) { + baseName = `_${baseName}`; + } + + // Truncate if too long + if (baseName.length > maxLength) { + baseName = baseName.slice(0, maxLength); + } + + return baseName; +}; diff --git a/packages/@n8n/utils/src/index.ts b/packages/@n8n/utils/src/index.ts index 395a5ff5fd2..e4aaf36e01f 100644 --- a/packages/@n8n/utils/src/index.ts +++ b/packages/@n8n/utils/src/index.ts @@ -7,3 +7,4 @@ export * from './search/reRankSearchResults'; export * from './search/sublimeSearch'; export * from './sort/sortByProperty'; export * from './string/truncate'; +export * from './files/sanitize'; diff --git a/packages/cli/BREAKING-CHANGES.md b/packages/cli/BREAKING-CHANGES.md index eb72ee82c2e..e7550290b37 100644 --- a/packages/cli/BREAKING-CHANGES.md +++ b/packages/cli/BREAKING-CHANGES.md @@ -2,6 +2,16 @@ This list shows all the versions which include breaking changes and how to upgrade. +# 1.122.0 + +### What changed? + +The way to add third-party dependencies to the `n8nio/runners` image has changed. More details [here](https://docs.n8n.io/hosting/configuration/task-runners/#adding-extra-dependencies). + +### When is action necessary? + +If you adding third-party dependencies to the `n8nio/runners` image using `package.json` and `extras.txt` and building the image yourself, please extend the image as instructed in the link above. + # 1.113.0 ### What changed? diff --git a/packages/cli/package.json b/packages/cli/package.json index b8da4cc67b9..2705fd255e5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -110,6 +110,7 @@ "@n8n/permissions": "workspace:*", "@n8n/task-runner": "workspace:*", "@n8n/typeorm": "catalog:", + "@n8n/utils": "workspace:*", "@n8n_io/ai-assistant-sdk": "catalog:", "@n8n_io/license-sdk": "2.24.1", "@rudderstack/rudder-sdk-node": "2.1.4", diff --git a/packages/cli/src/__tests__/active-executions.test.ts b/packages/cli/src/__tests__/active-executions.test.ts index 1d9dad8fd87..a0f85004e1a 100644 --- a/packages/cli/src/__tests__/active-executions.test.ts +++ b/packages/cli/src/__tests__/active-executions.test.ts @@ -65,6 +65,7 @@ describe('ActiveExecutions', () => { id: '123', name: 'Test workflow 1', active: false, + activeVersionId: null, isArchived: false, createdAt: new Date(), updatedAt: new Date(), diff --git a/packages/cli/src/__tests__/active-workflow-manager.test.ts b/packages/cli/src/__tests__/active-workflow-manager.test.ts index 734d9c95635..1bb3bce9943 100644 --- a/packages/cli/src/__tests__/active-workflow-manager.test.ts +++ b/packages/cli/src/__tests__/active-workflow-manager.test.ts @@ -1,5 +1,5 @@ import { mockLogger } from '@n8n/backend-test-utils'; -import type { WorkflowEntity, WorkflowRepository } from '@n8n/db'; +import type { WorkflowEntity, WorkflowHistory, WorkflowRepository } from '@n8n/db'; import { mock } from 'jest-mock-extended'; import type { InstanceSettings } from 'n8n-core'; import type { @@ -136,7 +136,9 @@ describe('ActiveWorkflowManager', () => { activeWorkflowManager, 'addTriggersAndPollers', ); - workflowRepository.findById.mockResolvedValue(mock({ active: false })); + workflowRepository.findById.mockResolvedValue( + mock({ active: false, activeVersionId: null, activeVersion: null }), + ); const added = await activeWorkflowManager.add('some-id', mode); @@ -165,4 +167,76 @@ describe('ActiveWorkflowManager', () => { expect(getAllActiveIds).toHaveBeenCalledTimes(1); }); }); + + describe('activateWorkflow', () => { + beforeEach(() => { + // Set up as leader to allow workflow activation + Object.assign(instanceSettings, { isLeader: true }); + }); + + test('should use active version when calling executeErrorWorkflow on activation failure', async () => { + // Create different nodes for draft vs active version + const draftNodes = [ + { + id: 'draft-node-1', + name: 'Draft Webhook', + type: 'n8n-nodes-base.webhook', + typeVersion: 1, + position: [0, 0] as [number, number], + parameters: {}, + }, + ]; + + const activeNodes = [ + { + id: 'active-node-1', + name: 'Active Webhook', + type: 'n8n-nodes-base.webhook', + typeVersion: 1, + position: [0, 0] as [number, number], + parameters: {}, + }, + ]; + + const activeVersion = mock({ + versionId: 'v1', + workflowId: 'workflow-1', + nodes: activeNodes, + connections: {}, + authors: 'test-user', + createdAt: new Date(), + updatedAt: new Date(), + }); + + const workflowEntity = mock({ + id: 'workflow-1', + name: 'Test Workflow', + active: true, + activeVersionId: activeVersion.versionId, + nodes: draftNodes, + connections: {}, + activeVersion, + }); + + workflowRepository.findById.mockResolvedValue(workflowEntity); + + // Mock the add method to throw an error (simulating activation failure) + jest.spyOn(activeWorkflowManager, 'add').mockRejectedValue(new Error('Authorization failed')); + + const executeErrorWorkflowSpy = jest + .spyOn(activeWorkflowManager, 'executeErrorWorkflow') + .mockImplementation(() => {}); + + await activeWorkflowManager['activateWorkflow']('workflow-1', 'init'); + + expect(executeErrorWorkflowSpy).toHaveBeenCalled(); + + // Get the workflow data that was passed to executeErrorWorkflow + const callArgs = executeErrorWorkflowSpy.mock.calls[0]; + const workflowData = callArgs[1]; + + expect(workflowData.nodes).toEqual(activeNodes); + expect(workflowData.nodes[0].name).toBe('Active Webhook'); + }); + }); }); diff --git a/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts b/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts index 44730676f9c..1c23973a24d 100644 --- a/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts +++ b/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts @@ -12,6 +12,7 @@ import type { ExecuteWorkflowOptions, IRun, INodeExecutionData, + INode, } from 'n8n-workflow'; import type PCancelable from 'p-cancelable'; @@ -28,7 +29,12 @@ import { DataTableProxyService } from '@/modules/data-table/data-table-proxy.ser import { UrlService } from '@/services/url.service'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; import { Telemetry } from '@/telemetry'; -import { executeWorkflow, getBase, getRunData } from '@/workflow-execute-additional-data'; +import { + executeWorkflow, + getBase, + getRunData, + getWorkflowData, +} from '@/workflow-execute-additional-data'; import * as WorkflowHelpers from '@/workflow-helpers'; const EXECUTION_ID = '123'; @@ -130,7 +136,15 @@ describe('WorkflowExecuteAdditionalData', () => { beforeEach(() => { workflowRepository.get.mockResolvedValue( - mock({ id: EXECUTION_ID, nodes: [] }), + mock({ + id: EXECUTION_ID, + name: 'Test Workflow', + active: false, + activeVersionId: null, + activeVersion: null, + nodes: [], + connections: {}, + }), ); activeExecutions.add.mockResolvedValue(EXECUTION_ID); processRunExecutionData.mockReturnValue(getCancelablePromise(runWithData)); @@ -279,6 +293,174 @@ describe('WorkflowExecuteAdditionalData', () => { }); }); + describe('getWorkflowData', () => { + beforeEach(() => { + workflowRepository.get.mockClear(); + }); + + it('should load and use active version when workflow is active', async () => { + const activeVersionNodes: INode[] = [ + mock({ + id: 'active-node', + type: 'n8n-nodes-base.set', + name: 'Active Node', + typeVersion: 1, + parameters: {}, + position: [250, 300], + }), + ]; + const activeVersionConnections = { 'Active Node': {} }; + const currentNodes: INode[] = [ + mock({ + id: 'current-node', + type: 'n8n-nodes-base.set', + name: 'Current Node', + typeVersion: 1, + parameters: {}, + position: [250, 300], + }), + ]; + const currentConnections = { 'Current Node': {} }; + + workflowRepository.get.mockResolvedValue( + mock({ + id: 'workflow-123', + name: 'Test Workflow', + active: true, + activeVersionId: 'version-456', + nodes: currentNodes, + connections: currentConnections, + activeVersion: mock({ + versionId: 'version-456', + workflowId: 'workflow-123', + nodes: activeVersionNodes, + connections: activeVersionConnections, + authors: 'user1', + createdAt: new Date(), + updatedAt: new Date(), + }), + }), + ); + + const result = await getWorkflowData({ id: 'workflow-123' }, 'parent-workflow-id'); + + expect(result.nodes).toEqual(activeVersionNodes); + expect(result.connections).toEqual(activeVersionConnections); + expect(workflowRepository.get).toHaveBeenCalledWith( + { id: 'workflow-123' }, + { relations: ['activeVersion', 'tags'] }, + ); + }); + + it('should use current version when workflow has no active version', async () => { + const currentNodes: INode[] = [ + mock({ + id: 'current-node', + type: 'n8n-nodes-base.set', + name: 'Current Node', + typeVersion: 1, + parameters: {}, + position: [250, 300], + }), + ]; + const currentConnections = { 'Current Node': {} }; + + workflowRepository.get.mockResolvedValue( + mock({ + id: 'workflow-123', + name: 'Test Workflow', + active: false, + activeVersionId: null, + nodes: currentNodes, + connections: currentConnections, + activeVersion: null, + }), + ); + + const result = await getWorkflowData({ id: 'workflow-123' }, 'parent-workflow-id'); + + expect(result.nodes).toEqual(currentNodes); + expect(result.connections).toEqual(currentConnections); + }); + + it('should load activeVersion relation when tags are disabled', async () => { + const globalConfig = Container.get(GlobalConfig); + globalConfig.tags.disabled = true; + + workflowRepository.get.mockResolvedValue( + mock({ + id: 'workflow-123', + active: false, + activeVersionId: null, + nodes: [], + connections: {}, + activeVersion: null, + }), + ); + + await getWorkflowData({ id: 'workflow-123' }, 'parent-workflow-id'); + + expect(workflowRepository.get).toHaveBeenCalledWith( + { id: 'workflow-123' }, + { relations: ['activeVersion'] }, + ); + + globalConfig.tags.disabled = false; + }); + + it('should throw error when workflow does not exist', async () => { + workflowRepository.get.mockResolvedValue(null); + + await expect(getWorkflowData({ id: 'non-existent' }, 'parent-workflow-id')).rejects.toThrow( + 'Workflow does not exist', + ); + }); + + it('should use provided workflow code when id is not provided', async () => { + const workflowCode = mock({ + id: 'code-workflow', + name: 'Code Workflow', + active: false, + nodes: [ + mock({ + id: 'node1', + type: 'n8n-nodes-base.set', + name: 'Node 1', + typeVersion: 1, + parameters: {}, + position: [250, 300], + }), + ], + connections: {}, + }); + + const result = await getWorkflowData({ code: workflowCode }, 'parent-workflow-id'); + + expect(result).toEqual(workflowCode); + expect(workflowRepository.get).not.toHaveBeenCalled(); + }); + + it('should set parent workflow settings when not provided in code', async () => { + const workflowCode = mock({ + id: 'code-workflow', + name: 'Code Workflow', + active: false, + nodes: [], + connections: {}, + settings: undefined, + }); + const parentSettings = { executionOrder: 'v1' as const }; + + const result = await getWorkflowData( + { code: workflowCode }, + 'parent-workflow-id', + parentSettings, + ); + + expect(result.settings).toEqual(parentSettings); + }); + }); + describe('getBase', () => { const mockWebhookBaseUrl = 'webhook-base-url.com'; jest.spyOn(urlService, 'getWebhookBaseUrl').mockReturnValue(mockWebhookBaseUrl); diff --git a/packages/cli/src/active-workflow-manager.ts b/packages/cli/src/active-workflow-manager.ts index 3f137b956e6..7d952adfbc6 100644 --- a/packages/cli/src/active-workflow-manager.ts +++ b/packages/cli/src/active-workflow-manager.ts @@ -144,11 +144,11 @@ export class ActiveWorkflowManager { */ async isActive(workflowId: WorkflowId) { const workflow = await this.workflowRepository.findOne({ - select: ['active'], + select: ['activeVersionId'], where: { id: workflowId }, }); - return !!workflow?.active; + return !!workflow?.activeVersionId; } /** @@ -248,18 +248,27 @@ export class ActiveWorkflowManager { async clearWebhooks(workflowId: WorkflowId) { const workflowData = await this.workflowRepository.findOne({ where: { id: workflowId }, + relations: { activeVersion: true }, }); if (workflowData === null) { throw new UnexpectedError('Could not find workflow', { extra: { workflowId } }); } + if (!workflowData.activeVersion) { + throw new UnexpectedError('Active version not found for workflow', { + extra: { workflowId }, + }); + } + + const { nodes, connections } = workflowData.activeVersion; + const workflow = new Workflow({ id: workflowId, name: workflowData.name, - nodes: workflowData.nodes, - connections: workflowData.connections, - active: workflowData.active, + nodes, + connections, + active: true, nodeTypes: this.nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings, @@ -488,8 +497,17 @@ export class ActiveWorkflowManager { }, ); + if (!dbWorkflow.activeVersion) { + throw new UnexpectedError('Active version not found for workflow', { + extra: { workflowId: dbWorkflow.id }, + }); + } + + const { nodes, connections } = dbWorkflow.activeVersion; + const workflowForError = { ...dbWorkflow, nodes, connections }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - this.executeErrorWorkflow(error, dbWorkflow, 'internal'); + this.executeErrorWorkflow(error, workflowForError, 'internal'); // do not keep trying to activate on authorization error // eslint-disable-next-line @typescript-eslint/no-unsafe-call @@ -568,7 +586,7 @@ export class ActiveWorkflowManager { }); } - if (['init', 'leadershipChange'].includes(activationMode) && !dbWorkflow.active) { + if (['init', 'leadershipChange'].includes(activationMode) && !dbWorkflow.activeVersion) { this.logger.debug( `Skipping workflow ${formatWorkflow(dbWorkflow)} as it is no longer active`, { workflowId: dbWorkflow.id }, @@ -577,12 +595,23 @@ export class ActiveWorkflowManager { return added; } + // Get workflow data from the active version + if (!dbWorkflow.activeVersion) { + throw new UnexpectedError('Active version not found for workflow', { + extra: { workflowId: dbWorkflow.id }, + }); + } + + const { nodes, connections } = dbWorkflow.activeVersion; + dbWorkflow.nodes = nodes; + dbWorkflow.connections = connections; + workflow = new Workflow({ id: dbWorkflow.id, name: dbWorkflow.name, - nodes: dbWorkflow.nodes, - connections: dbWorkflow.connections, - active: dbWorkflow.active, + nodes, + connections, + active: true, nodeTypes: this.nodeTypes, staticData: dbWorkflow.staticData, settings: dbWorkflow.settings, @@ -683,7 +712,7 @@ export class ActiveWorkflowManager { const error = ensureError(e); const { message } = error; - await this.workflowRepository.update(workflowId, { active: false }); + await this.workflowRepository.update(workflowId, { active: false, activeVersionId: null }); this.push.broadcast({ type: 'workflowFailedToActivate', diff --git a/packages/cli/src/auth/auth.service.ts b/packages/cli/src/auth/auth.service.ts index 77457a096ec..ee139c40167 100644 --- a/packages/cli/src/auth/auth.service.ts +++ b/packages/cli/src/auth/auth.service.ts @@ -87,6 +87,9 @@ export class AuthService { '/types/nodes.json', '/types/credentials.json', '/mcp-oauth/authorize/', + + // Skip browser ID check for chat hub attachments + `/${restEndpoint}/chat/conversations/:sessionId/messages/:messageId/attachments/:index`, ]; } diff --git a/packages/cli/src/databases/repositories/__tests__/execution.repository.test.ts b/packages/cli/src/databases/repositories/__tests__/execution.repository.test.ts index ebd9c26c7c0..1ae237632f0 100644 --- a/packages/cli/src/databases/repositories/__tests__/execution.repository.test.ts +++ b/packages/cli/src/databases/repositories/__tests__/execution.repository.test.ts @@ -68,7 +68,9 @@ describe('ExecutionRepository', () => { await executionRepository.deleteExecutionsByFilter({ id: '1' }, ['1'], { ids: ['1'] }); - expect(binaryDataService.deleteMany).toHaveBeenCalledWith([{ executionId: '1', workflowId }]); + expect(binaryDataService.deleteMany).toHaveBeenCalledWith([ + { type: 'execution', executionId: '1', workflowId }, + ]); }); }); diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts index 164362c94d4..986d8b796e7 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts @@ -27,6 +27,8 @@ import type { ExportableFolder } from '../types/exportable-folders'; import type { ExportableProject } from '../types/exportable-project'; import { SourceControlContext } from '../types/source-control-context'; +import type { ActiveWorkflowManager } from '@/active-workflow-manager'; + jest.mock('fast-glob'); const globalAdminContext = new SourceControlContext( @@ -50,11 +52,12 @@ describe('SourceControlImportService', () => { const sourceControlScopedService = mock(); const variableService = mock(); const variablesRepository = mock(); + const activeWorkflowManager = mock(); const service = new SourceControlImportService( mockLogger, mock(), variableService, - mock(), + activeWorkflowManager, mock(), projectRepository, mock(), @@ -259,6 +262,232 @@ describe('SourceControlImportService', () => { expect.any(Object), ); }); + + it('should set new workflows as inactive with null activeVersionId', async () => { + const mockUserId = 'user-id-123'; + const mockWorkflowFile = '/mock/workflow1.json'; + const mockWorkflowData = { + id: 'workflow1', + name: 'New Workflow', + nodes: [], + parentFolderId: null, + }; + const candidates = [mock({ file: mockWorkflowFile, id: 'workflow1' })]; + + projectRepository.getPersonalProjectForUserOrFail.mockResolvedValue( + Object.assign(new Project(), { id: 'project1', type: 'personal' }), + ); + workflowRepository.findByIds.mockResolvedValue([]); + folderRepository.find.mockResolvedValue([]); + sharedWorkflowRepository.findWithFields.mockResolvedValue([]); + workflowRepository.upsert.mockResolvedValue({ + identifiers: [{ id: 'workflow1' }], + generatedMaps: [], + raw: [], + }); + + fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData)); + + await service.importWorkflowFromWorkFolder(candidates, mockUserId); + + expect(workflowRepository.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'workflow1', + active: false, + activeVersionId: null, + }), + ['id'], + ); + expect(activeWorkflowManager.remove).not.toHaveBeenCalled(); + expect(activeWorkflowManager.add).not.toHaveBeenCalled(); + }); + + it('should keep existing inactive workflows inactive', async () => { + const mockUserId = 'user-id-123'; + const mockWorkflowFile = '/mock/workflow1.json'; + const mockWorkflowData = { + id: 'workflow1', + name: 'Existing Workflow', + nodes: [], + parentFolderId: null, + }; + const candidates = [mock({ file: mockWorkflowFile, id: 'workflow1' })]; + + projectRepository.getPersonalProjectForUserOrFail.mockResolvedValue( + Object.assign(new Project(), { id: 'project1', type: 'personal' }), + ); + workflowRepository.findByIds.mockResolvedValue([ + Object.assign(new WorkflowEntity(), { + id: 'workflow1', + name: 'Existing Workflow', + active: false, + activeVersionId: null, + }), + ]); + folderRepository.find.mockResolvedValue([]); + sharedWorkflowRepository.findWithFields.mockResolvedValue([]); + workflowRepository.upsert.mockResolvedValue({ + identifiers: [{ id: 'workflow1' }], + generatedMaps: [], + raw: [], + }); + + fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData)); + + await service.importWorkflowFromWorkFolder(candidates, mockUserId); + + expect(workflowRepository.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'workflow1', + active: false, + activeVersionId: null, + }), + ['id'], + ); + expect(activeWorkflowManager.remove).not.toHaveBeenCalled(); + expect(activeWorkflowManager.add).not.toHaveBeenCalled(); + }); + + it('should reactivate existing active workflows', async () => { + const mockUserId = 'user-id-123'; + const mockWorkflowFile = '/mock/workflow1.json'; + const mockWorkflowData = { + id: 'workflow1', + name: 'Active Workflow', + nodes: [], + parentFolderId: null, + }; + const candidates = [mock({ file: mockWorkflowFile, id: 'workflow1' })]; + + projectRepository.getPersonalProjectForUserOrFail.mockResolvedValue( + Object.assign(new Project(), { id: 'project1', type: 'personal' }), + ); + workflowRepository.findByIds.mockResolvedValue([ + Object.assign(new WorkflowEntity(), { + id: 'workflow1', + name: 'Active Workflow', + active: true, + activeVersionId: 'version-123', + }), + ]); + folderRepository.find.mockResolvedValue([]); + sharedWorkflowRepository.findWithFields.mockResolvedValue([]); + workflowRepository.upsert.mockResolvedValue({ + identifiers: [{ id: 'workflow1' }], + generatedMaps: [], + raw: [], + }); + + fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData)); + + await service.importWorkflowFromWorkFolder(candidates, mockUserId); + + expect(workflowRepository.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'workflow1', + active: true, + activeVersionId: 'version-123', + }), + ['id'], + ); + expect(activeWorkflowManager.remove).toHaveBeenCalledWith('workflow1'); + expect(activeWorkflowManager.add).toHaveBeenCalledWith('workflow1', 'activate'); + }); + + it('should deactivate archived workflows even if they were previously active', async () => { + const mockUserId = 'user-id-123'; + const mockWorkflowFile = '/mock/workflow1.json'; + const mockWorkflowData = { + id: 'workflow1', + name: 'Archived Workflow', + nodes: [], + parentFolderId: null, + isArchived: true, + }; + const candidates = [mock({ file: mockWorkflowFile, id: 'workflow1' })]; + + projectRepository.getPersonalProjectForUserOrFail.mockResolvedValue( + Object.assign(new Project(), { id: 'project1', type: 'personal' }), + ); + workflowRepository.findByIds.mockResolvedValue([ + Object.assign(new WorkflowEntity(), { + id: 'workflow1', + name: 'Archived Workflow', + active: true, + activeVersionId: 'version-123', + }), + ]); + folderRepository.find.mockResolvedValue([]); + sharedWorkflowRepository.findWithFields.mockResolvedValue([]); + workflowRepository.upsert.mockResolvedValue({ + identifiers: [{ id: 'workflow1' }], + generatedMaps: [], + raw: [], + }); + + fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData)); + + await service.importWorkflowFromWorkFolder(candidates, mockUserId); + + expect(workflowRepository.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'workflow1', + active: false, + activeVersionId: null, + }), + ['id'], + ); + expect(activeWorkflowManager.remove).toHaveBeenCalledWith('workflow1'); + expect(activeWorkflowManager.add).not.toHaveBeenCalled(); + }); + + it('should handle activation errors gracefully', async () => { + const mockUserId = 'user-id-123'; + const mockWorkflowFile = '/mock/workflow1.json'; + const mockWorkflowData = { + id: 'workflow1', + name: 'Workflow with activation error', + nodes: [], + parentFolderId: null, + }; + const candidates = [mock({ file: mockWorkflowFile, id: 'workflow1' })]; + + projectRepository.getPersonalProjectForUserOrFail.mockResolvedValue( + Object.assign(new Project(), { id: 'project1', type: 'personal' }), + ); + workflowRepository.findByIds.mockResolvedValue([ + Object.assign(new WorkflowEntity(), { + id: 'workflow1', + name: 'Workflow with activation error', + active: true, + activeVersionId: 'version-123', + }), + ]); + folderRepository.find.mockResolvedValue([]); + sharedWorkflowRepository.findWithFields.mockResolvedValue([]); + workflowRepository.upsert.mockResolvedValue({ + identifiers: [{ id: 'workflow1' }], + generatedMaps: [], + raw: [], + }); + workflowRepository.update.mockResolvedValue({ + generatedMaps: [], + raw: [], + affected: 1, + }); + activeWorkflowManager.add.mockRejectedValue(new Error('Activation failed')); + + fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData)); + + const result = await service.importWorkflowFromWorkFolder(candidates, mockUserId); + + expect(mockLogger.error).toHaveBeenCalledWith( + 'Failed to activate workflow workflow1', + expect.any(Object), + ); + expect(workflowRepository.update).toHaveBeenCalled(); + expect(result).toEqual([{ id: 'workflow1', name: mockWorkflowFile }]); + }); }); describe('getRemoteCredentialsFromFiles', () => { diff --git a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts index 240f2f92f16..11d8df771c0 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts @@ -634,7 +634,7 @@ export class SourceControlImportService { const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail(userId); const candidateIds = candidates.map((c) => c.id); const existingWorkflows = await this.workflowRepository.findByIds(candidateIds, { - fields: ['id', 'name', 'versionId', 'active'], + fields: ['id', 'name', 'versionId', 'active', 'activeVersionId'], }); const folders = await this.folderRepository.find({ select: ['id'] }); @@ -662,9 +662,18 @@ export class SourceControlImportService { // IWorkflowToImport having it typed as boolean. Imported workflows are always inactive if they are new, // and existing workflows use the existing workflow's active status unless they have been archived on the remote. // In that case, we deactivate the existing workflow on pull and turn it archived. - importedWorkflow.active = existingWorkflow - ? existingWorkflow.active && !importedWorkflow.isArchived - : false; + if (existingWorkflow) { + if (importedWorkflow.isArchived) { + importedWorkflow.active = false; + importedWorkflow.activeVersionId = null; + } else { + importedWorkflow.active = !!existingWorkflow.activeVersionId; + importedWorkflow.activeVersionId = existingWorkflow.activeVersionId; + } + } else { + importedWorkflow.active = false; + importedWorkflow.activeVersionId = null; + } const parentFolderId = importedWorkflow.parentFolderId ?? ''; @@ -695,7 +704,7 @@ export class SourceControlImportService { repository: this.sharedWorkflowRepository, }); - if (existingWorkflow?.active) { + if (existingWorkflow?.activeVersionId) { await this.activateImportedWorkflow({ existingWorkflow, importedWorkflow }); } @@ -733,7 +742,7 @@ export class SourceControlImportService { this.logger.debug(`Deactivating workflow id ${existingWorkflow.id}`); await this.activeWorkflowManager.remove(existingWorkflow.id); - if (importedWorkflow.active) { + if (importedWorkflow.activeVersionId) { // try activating the imported workflow this.logger.debug(`Reactivating workflow id ${existingWorkflow.id}`); await this.activeWorkflowManager.add(existingWorkflow.id, 'activate'); diff --git a/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts b/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts index 96b30e7ae11..7ba3fdcc81b 100644 --- a/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts +++ b/packages/cli/src/eventbus/message-event-bus/message-event-bus.ts @@ -6,7 +6,7 @@ import { Service } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import type { DeleteResult } from '@n8n/typeorm'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import -import { In } from '@n8n/typeorm'; +import { In, IsNull, Not } from '@n8n/typeorm'; import EventEmitter from 'events'; import uniqby from 'lodash/uniqBy'; import type { MessageEventBusDestinationOptions } from 'n8n-workflow'; @@ -165,7 +165,7 @@ export class MessageEventBus extends EventEmitter { if (unfinishedExecutionIds.length > 0) { const activeWorkflows = await this.workflowRepository.find({ - where: { active: true }, + where: { activeVersionId: Not(IsNull()) }, select: ['id', 'name'], }); if (activeWorkflows.length > 0) { diff --git a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts index 576b1fc24cb..9eb4f2f2a70 100644 --- a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts @@ -172,6 +172,7 @@ describe('LogStreamingEventRelay', () => { id: 'wf202', name: 'Test Workflow', active: true, + activeVersionId: 'some-version-id', nodes: [], connections: {}, staticData: undefined, @@ -608,6 +609,7 @@ describe('LogStreamingEventRelay', () => { id: 'wf303', name: 'Test Workflow with Nodes', active: true, + activeVersionId: 'some-version-id', nodes: [ { id: 'node1', @@ -656,6 +658,7 @@ describe('LogStreamingEventRelay', () => { id: 'wf404', name: 'Test Workflow with Completed Node', active: true, + activeVersionId: 'some-version-id', nodes: [ { id: 'node1', diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index bce4deb370a..2566a1cd923 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -1215,6 +1215,7 @@ describe('TelemetryEventRelay', () => { id: 'workflow123', name: 'Test Workflow', active: true, + activeVersionId: 'some-version-id', nodes: [ { id: 'node1', diff --git a/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts b/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts index 5149f7a05c0..8fae0d993c7 100644 --- a/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts +++ b/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts @@ -62,6 +62,7 @@ describe('Execution Lifecycle Hooks', () => { id: workflowId, name: 'Test Workflow', active: true, + activeVersionId: 'some-version-id', isArchived: false, connections: {}, nodes: [ diff --git a/packages/cli/src/execution-lifecycle/shared/shared-hook-functions.ts b/packages/cli/src/execution-lifecycle/shared/shared-hook-functions.ts index c1a10d01238..bc8e27f7763 100644 --- a/packages/cli/src/execution-lifecycle/shared/shared-hook-functions.ts +++ b/packages/cli/src/execution-lifecycle/shared/shared-hook-functions.ts @@ -38,6 +38,7 @@ export function prepareExecutionDataForDbUpdate(parameters: { 'id', 'name', 'active', + 'activeVersionId', 'isArchived', 'createdAt', 'updatedAt', diff --git a/packages/cli/src/executions/execution.service.ts b/packages/cli/src/executions/execution.service.ts index cb6b3163386..341e01de252 100644 --- a/packages/cli/src/executions/execution.service.ts +++ b/packages/cli/src/executions/execution.service.ts @@ -183,6 +183,7 @@ export class ExecutionService { const executionMode = 'retry'; execution.workflowData.active = false; + execution.workflowData.activeVersionId = null; // Start the workflow const data: IWorkflowExecutionDataProcess = { diff --git a/packages/cli/src/executions/execution.utils.ts b/packages/cli/src/executions/execution.utils.ts new file mode 100644 index 00000000000..56409374035 --- /dev/null +++ b/packages/cli/src/executions/execution.utils.ts @@ -0,0 +1,15 @@ +import type { IWorkflowBase } from 'n8n-workflow'; + +/** + * Determines the active status of a workflow from workflow data. + * + * This function handles backward compatibility: + * - Newer workflow data uses `activeVersionId` (string = active, null/undefined = inactive) + * - Older workflow data (before activeVersionId was introduced) falls back to the `active` boolean field + * + * @param workflowData - Workflow data + * @returns true if the workflow should be considered active, false otherwise + */ +export function getWorkflowActiveStatusFromWorkflowData(workflowData: IWorkflowBase): boolean { + return !!workflowData.activeVersionId || workflowData.active; +} diff --git a/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts b/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts index e827e5903cb..c2d353822bd 100644 --- a/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts +++ b/packages/cli/src/middlewares/list-query/dtos/workflow.select.dto.ts @@ -6,6 +6,7 @@ export class WorkflowSelect extends BaseSelect { 'id', // always included downstream 'name', 'active', + 'activeVersionId', 'tags', 'createdAt', 'updatedAt', diff --git a/packages/cli/src/modules/breaking-changes/__tests__/test-helpers.ts b/packages/cli/src/modules/breaking-changes/__tests__/test-helpers.ts index 86872fbc906..40a363d80b0 100644 --- a/packages/cli/src/modules/breaking-changes/__tests__/test-helpers.ts +++ b/packages/cli/src/modules/breaking-changes/__tests__/test-helpers.ts @@ -6,6 +6,8 @@ export const createWorkflow = (id: string, name: string, nodes: INode[], active id, name, active, + activeVersionId: active ? 'v1' : null, + versionId: 'v1', nodes, statistics: [ { diff --git a/packages/cli/src/modules/breaking-changes/breaking-changes.service.ts b/packages/cli/src/modules/breaking-changes/breaking-changes.service.ts index d7f01743a29..4828390cd8b 100644 --- a/packages/cli/src/modules/breaking-changes/breaking-changes.service.ts +++ b/packages/cli/src/modules/breaking-changes/breaking-changes.service.ts @@ -87,7 +87,7 @@ export class BreakingChangeService { // Process workflows in batches for (let skip = 0; skip < totalWorkflows; skip += this.batchSize) { const workflows = await this.workflowRepository.find({ - select: ['id', 'name', 'active', 'nodes', 'updatedAt', 'statistics'], + select: ['id', 'name', 'active', 'activeVersionId', 'nodes', 'updatedAt', 'statistics'], skip, take: this.batchSize, order: { id: 'ASC' }, @@ -115,7 +115,7 @@ export class BreakingChangeService { const affectedWorkflow: BreakingChangeAffectedWorkflow = { id: workflow.id, name: workflow.name, - active: workflow.active, + active: !!workflow.activeVersionId, issues: workflowDetectionResult.issues, numberOfExecutions: workflow.statistics.reduce( (acc, cur) => acc + (cur.count || 0), diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/binary-data-storage.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/binary-data-storage.rule.test.ts index 6d76fe25412..dd8d55f394e 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/binary-data-storage.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/binary-data-storage.rule.test.ts @@ -1,3 +1,4 @@ +import type { ExecutionsConfig } from '@n8n/config'; import { mock } from 'jest-mock-extended'; import type { BinaryDataConfig } from 'n8n-core'; @@ -5,23 +6,26 @@ import { BinaryDataStorageRule } from '../binary-data-storage.rule'; describe('BinaryDataStorageRule', () => { let rule: BinaryDataStorageRule; - const config: BinaryDataConfig = mock(); + const binaryDataConfig: BinaryDataConfig = mock(); + const executionsConfig: ExecutionsConfig = mock(); beforeEach(() => { - rule = new BinaryDataStorageRule(config); + rule = new BinaryDataStorageRule(binaryDataConfig, executionsConfig); }); describe('detect()', () => { it('should not be affected if mode is not default', async () => { - config.mode = 'filesystem'; + binaryDataConfig.mode = 'filesystem'; + executionsConfig.mode = 'regular'; const result = await rule.detect(); expect(result.isAffected).toBe(false); expect(result.instanceIssues).toHaveLength(0); }); - it('should be affected if mode is default', async () => { - config.mode = 'default'; + it('should be affected if mode is default and execution mode is regular', async () => { + binaryDataConfig.mode = 'default'; + executionsConfig.mode = 'regular'; const result = await rule.detect(); expect(result.isAffected).toBe(true); @@ -30,5 +34,16 @@ describe('BinaryDataStorageRule', () => { expect(result.recommendations).toHaveLength(3); expect(result.recommendations[0].action).toBe('Ensure adequate disk space'); }); + + it('should be affected if mode is default and execution mode is queue', async () => { + binaryDataConfig.mode = 'default'; + executionsConfig.mode = 'queue'; + const result = await rule.detect(); + + expect(result.isAffected).toBe(true); + expect(result.instanceIssues).toHaveLength(1); + expect(result.instanceIssues[0].title).toBe('Binary data storage mode changed'); + expect(result.recommendations).toHaveLength(0); + }); }); }); diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/binary-data-storage.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v2/binary-data-storage.rule.ts index 6a302411e71..0b67cbfe2a4 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/binary-data-storage.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/binary-data-storage.rule.ts @@ -1,3 +1,4 @@ +import { ExecutionsConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import { BinaryDataConfig } from 'n8n-core'; @@ -10,16 +11,19 @@ import { BreakingChangeCategory } from '../../types'; @Service() export class BinaryDataStorageRule implements IBreakingChangeInstanceRule { - constructor(private readonly config: BinaryDataConfig) {} + constructor( + private readonly config: BinaryDataConfig, + private readonly executionsConfig: ExecutionsConfig, + ) {} id: string = 'binary-data-storage-v2'; getMetadata(): BreakingChangeRuleMetadata { return { version: 'v2', - title: 'Disable binary data in-memory mode by default', + title: 'Binary data in-memory mode is removed', description: - 'Binary files are now stored on disk by default instead of in memory, removing the 512MB file size limit', + 'Binary files are now stored on disk (default in regular mode) or in database (default in queue mode) instead of in memory', category: BreakingChangeCategory.infrastructure, severity: 'low', documentationUrl: @@ -36,30 +40,36 @@ export class BinaryDataStorageRule implements IBreakingChangeInstanceRule { }; } + const isRegularMode = this.executionsConfig.mode === 'regular'; + const result: InstanceDetectionReport = { isAffected: true, instanceIssues: [ { title: 'Binary data storage mode changed', - description: `Binary files are now stored in ${this.config.localStoragePath} directory by default instead of in memory. This removes the previous 512MB file size limit but increases disk usage.`, + description: isRegularMode + ? `Binary files are now stored in ${this.config.localStoragePath} directory by default (for regular mode) instead of in memory.` + : 'Binary files are now stored in the database by default (for queue mode) instead of in memory.', level: 'info', }, ], - recommendations: [ - { - action: 'Ensure adequate disk space', - description: `Verify sufficient disk space is available for binary file storage in the ${this.config.localStoragePath} directory`, - }, - { - action: 'Configure persistent storage', - description: - 'If using containers, ensure the binary data directory is mounted on a persistent volume', - }, - { - action: 'Include in backups', - description: 'Add the binary data folder to your backup procedures', - }, - ], + recommendations: isRegularMode + ? [ + { + action: 'Ensure adequate disk space', + description: `Verify sufficient disk space is available for binary file storage in the ${this.config.localStoragePath} directory`, + }, + { + action: 'Configure persistent storage', + description: + 'If using containers, ensure the binary data directory is mounted on a persistent volume', + }, + { + action: 'Include in backups', + description: 'Add the binary data folder to your backup procedures', + }, + ] + : [], }; return result; diff --git a/packages/cli/src/modules/chat-hub/__tests__/chat-hub.service.integration.test.ts b/packages/cli/src/modules/chat-hub/__tests__/chat-hub.service.integration.test.ts index 59099868312..5df244e1f34 100644 --- a/packages/cli/src/modules/chat-hub/__tests__/chat-hub.service.integration.test.ts +++ b/packages/cli/src/modules/chat-hub/__tests__/chat-hub.service.integration.test.ts @@ -1,12 +1,15 @@ -import { testDb, testModules } from '@n8n/backend-test-utils'; +import { mockInstance, testDb, testModules } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; import { Container } from '@n8n/di'; +import { BinaryDataService } from 'n8n-core'; import { createAdmin, createMember } from '@test-integration/db/users'; import { ChatHubService } from '../chat-hub.service'; import { ChatHubMessageRepository } from '../chat-message.repository'; import { ChatHubSessionRepository } from '../chat-session.repository'; +mockInstance(BinaryDataService); + beforeAll(async () => { await testModules.loadModules(['chat-hub']); await testDb.init(); @@ -45,9 +48,9 @@ describe('chatHub', () => { describe('getConversations', () => { it('should list empty conversations', async () => { - const conversations = await chatHubService.getConversations(member.id); + const conversations = await chatHubService.getConversations(member.id, 20); expect(conversations).toBeDefined(); - expect(conversations).toHaveLength(0); + expect(conversations.data).toHaveLength(0); }); it("should list user's own conversations in expected order", async () => { @@ -80,11 +83,182 @@ describe('chatHub', () => { tools: [], }); - const conversations = await chatHubService.getConversations(member.id); - expect(conversations).toHaveLength(3); - expect(conversations[0].id).toBe(session1.id); - expect(conversations[1].id).toBe(session2.id); - expect(conversations[2].id).toBe(session3.id); + const conversations = await chatHubService.getConversations(member.id, 20); + expect(conversations.data).toHaveLength(3); + expect(conversations.data[0].id).toBe(session1.id); + expect(conversations.data[1].id).toBe(session2.id); + expect(conversations.data[2].id).toBe(session3.id); + }); + + describe('pagination', () => { + it('should return hasMore=false and nextCursor=null when all sessions fit in one page', async () => { + await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: member.id, + title: 'session 1', + lastMessageAt: new Date('2025-01-01T00:00:00Z'), + tools: [], + }); + + const conversations = await chatHubService.getConversations(member.id, 10); + + expect(conversations.data).toHaveLength(1); + expect(conversations.hasMore).toBe(false); + expect(conversations.nextCursor).toBeNull(); + }); + + it('should fetch next page using cursor', async () => { + const session1 = await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: member.id, + title: 'session 1', + lastMessageAt: new Date('2025-01-05T00:00:00Z'), + tools: [], + }); + + const session2 = await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: member.id, + title: 'session 2', + lastMessageAt: new Date('2025-01-04T00:00:00Z'), + tools: [], + }); + + const session3 = await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: member.id, + title: 'session 3', + lastMessageAt: new Date('2025-01-03T00:00:00Z'), + tools: [], + }); + + const session4 = await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: member.id, + title: 'session 4', + lastMessageAt: new Date('2025-01-02T00:00:00Z'), + tools: [], + }); + + // First page + const page1 = await chatHubService.getConversations(member.id, 2); + expect(page1.data).toHaveLength(2); + expect(page1.data[0].id).toBe(session1.id); + expect(page1.data[1].id).toBe(session2.id); + expect(page1.hasMore).toBe(true); + expect(page1.nextCursor).toBe(session2.id); + + // Second page using cursor + const page2 = await chatHubService.getConversations(member.id, 2, page1.nextCursor!); + expect(page2.data).toHaveLength(2); + expect(page2.data[0].id).toBe(session3.id); + expect(page2.data[1].id).toBe(session4.id); + expect(page2.hasMore).toBe(false); + expect(page2.nextCursor).toBeNull(); + }); + + it('should handle sessions with same lastMessageAt using id for ordering', async () => { + const sameDate = new Date('2025-01-01T00:00:00Z'); + + const session1 = await sessionsRepository.createChatSession({ + id: '00000000-0000-0000-0000-000000000001', + ownerId: member.id, + title: 'Session 1', + lastMessageAt: sameDate, + tools: [], + }); + + const session2 = await sessionsRepository.createChatSession({ + id: '00000000-0000-0000-0000-000000000002', + ownerId: member.id, + title: 'Session 2', + lastMessageAt: sameDate, + tools: [], + }); + + const session3 = await sessionsRepository.createChatSession({ + id: '00000000-0000-0000-0000-000000000003', + ownerId: member.id, + title: 'Session 3', + lastMessageAt: sameDate, + tools: [], + }); + + // Fetch first page + const page1 = await chatHubService.getConversations(member.id, 2); + expect(page1.data).toHaveLength(2); + expect(page1.data[0].id).toBe(session1.id); + expect(page1.data[1].id).toBe(session2.id); + expect(page1.hasMore).toBe(true); + + // Fetch second page + const page2 = await chatHubService.getConversations(member.id, 2, page1.nextCursor!); + expect(page2.data).toHaveLength(1); + expect(page2.data[0].id).toBe(session3.id); + expect(page2.hasMore).toBe(false); + }); + + it('should throw error when cursor session does not exist', async () => { + await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: member.id, + title: 'session 1', + lastMessageAt: new Date('2025-01-01T00:00:00Z'), + tools: [], + }); + + const nonExistentCursor = '00000000-0000-0000-0000-000000000000'; + + await expect( + chatHubService.getConversations(member.id, 10, nonExistentCursor), + ).rejects.toThrow('Cursor session not found'); + }); + + it('should throw error when cursor session belongs to different user', async () => { + await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: member.id, + title: 'Member Session', + lastMessageAt: new Date('2025-01-02T00:00:00Z'), + tools: [], + }); + + const adminSession = await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: admin.id, + title: 'Admin Session', + lastMessageAt: new Date('2025-01-01T00:00:00Z'), + tools: [], + }); + + await expect( + chatHubService.getConversations(member.id, 10, adminSession.id), + ).rejects.toThrow('Cursor session not found'); + }); + + it('should handle sessions with null lastMessageAt', async () => { + const session1 = await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: member.id, + title: 'Session with date', + lastMessageAt: new Date('2025-01-01T00:00:00Z'), + tools: [], + }); + + const session2 = await sessionsRepository.createChatSession({ + id: crypto.randomUUID(), + ownerId: member.id, + title: 'Session without date', + lastMessageAt: null, + tools: [], + }); + + const conversations = await chatHubService.getConversations(member.id, 10); + + expect(conversations.data).toHaveLength(2); + expect(conversations.data[0].id).toBe(session1.id); + expect(conversations.data[1].id).toBe(session2.id); + }); }); }); diff --git a/packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts b/packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts index ddcfcfecf6b..00e0f7c0c89 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts @@ -32,6 +32,7 @@ export class ChatHubAgentService { }, createdAt: agent.createdAt.toISOString(), updatedAt: agent.updatedAt.toISOString(), + allowFileUploads: true, })), }; } diff --git a/packages/cli/src/modules/chat-hub/chat-hub-message.entity.ts b/packages/cli/src/modules/chat-hub/chat-hub-message.entity.ts index e66830d6988..b433c937f72 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-message.entity.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-message.entity.ts @@ -11,6 +11,7 @@ import { } from '@n8n/typeorm'; import type { ChatHubSession } from './chat-hub-session.entity'; +import type { IBinaryData } from 'n8n-workflow'; @Entity({ name: 'chat_hub_messages' }) export class ChatHubMessage extends WithTimestamps { @@ -167,4 +168,13 @@ export class ChatHubMessage extends WithTimestamps { */ @Column({ type: 'varchar', length: 16, default: 'success' }) status: ChatHubMessageStatus; + + /** + * File attachments for the message (if any), stored as JSON. + * Storage strategy depends on the binary data mode configuration: + * - When using external storage (e.g., filesystem-v2): Only metadata is stored, with 'id' referencing the external location + * - When using default mode: Base64-encoded data is stored directly in the 'data' field + */ + @Column({ type: 'json', nullable: true }) + attachments: Array | null; } diff --git a/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts b/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts index 41a65870f64..cc569784e30 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts @@ -21,8 +21,10 @@ import { IWorkflowBase, MEMORY_BUFFER_WINDOW_NODE_TYPE, MEMORY_MANAGER_NODE_TYPE, + MERGE_NODE_TYPE, NodeConnectionTypes, OperationalError, + type IBinaryData, } from 'n8n-workflow'; import { v4 as uuidv4 } from 'uuid'; @@ -49,6 +51,7 @@ export class ChatHubWorkflowService { projectId: string, history: ChatHubMessage[], humanMessage: string, + attachments: IBinaryData[], credentials: INodeCredentials, model: ChatHubConversationModel, systemMessage: string | undefined, @@ -65,6 +68,7 @@ export class ChatHubWorkflowService { sessionId, history, humanMessage, + attachments, credentials, model, systemMessage, @@ -72,9 +76,11 @@ export class ChatHubWorkflowService { }); const newWorkflow = new WorkflowEntity(); + newWorkflow.versionId = uuidv4(); newWorkflow.name = `Chat ${sessionId}`; newWorkflow.active = false; + newWorkflow.activeVersionId = null; newWorkflow.nodes = nodes; newWorkflow.connections = connections; newWorkflow.settings = { @@ -124,6 +130,7 @@ export class ChatHubWorkflowService { newWorkflow.versionId = uuidv4(); newWorkflow.name = `Chat ${sessionId} (Title Generation)`; newWorkflow.active = false; + newWorkflow.activeVersionId = null; newWorkflow.nodes = nodes; newWorkflow.connections = connections; newWorkflow.settings = { @@ -147,6 +154,38 @@ export class ChatHubWorkflowService { }); } + prepareExecutionData( + triggerNode: INode, + sessionId: string, + message: string, + attachments: IBinaryData[], + ): IExecuteData[] { + // Attachments are already processed (id field populated) by the caller + return [ + { + node: triggerNode, + data: { + main: [ + [ + { + json: { + sessionId, + action: 'sendMessage', + chatInput: message, + files: attachments.map(({ data, ...metadata }) => metadata), + }, + binary: Object.fromEntries( + attachments.map((attachment, index) => [`data${index}`, attachment]), + ), + }, + ], + ], + }, + source: null, + }, + ]; + } + private getUniqueNodeName(originalName: string, existingNames: Set): string { if (!existingNames.has(originalName)) { return originalName; @@ -168,6 +207,7 @@ export class ChatHubWorkflowService { sessionId, history, humanMessage, + attachments, credentials, model, systemMessage, @@ -177,6 +217,7 @@ export class ChatHubWorkflowService { sessionId: ChatSessionId; history: ChatHubMessage[]; humanMessage: string; + attachments: IBinaryData[]; credentials: INodeCredentials; model: ChatHubConversationModel; systemMessage?: string; @@ -188,6 +229,7 @@ export class ChatHubWorkflowService { const memoryNode = this.buildMemoryNode(20); const restoreMemoryNode = this.buildRestoreMemoryNode(history); const clearMemoryNode = this.buildClearMemoryNode(); + const mergeNode = this.buildMergeNode(); const nodes: INode[] = [ chatTriggerNode, @@ -196,6 +238,7 @@ export class ChatHubWorkflowService { memoryNode, restoreMemoryNode, clearMemoryNode, + mergeNode, ]; const nodeNames = new Set(nodes.map((node) => node.name)); @@ -221,10 +264,18 @@ export class ChatHubWorkflowService { const connections: IConnections = { [NODE_NAMES.CHAT_TRIGGER]: { [NodeConnectionTypes.Main]: [ - [{ node: NODE_NAMES.RESTORE_CHAT_MEMORY, type: NodeConnectionTypes.Main, index: 0 }], + [ + { node: NODE_NAMES.RESTORE_CHAT_MEMORY, type: NodeConnectionTypes.Main, index: 0 }, + { node: NODE_NAMES.MERGE, type: NodeConnectionTypes.Main, index: 0 }, + ], ], }, [NODE_NAMES.RESTORE_CHAT_MEMORY]: { + [NodeConnectionTypes.Main]: [ + [{ node: NODE_NAMES.MERGE, type: NodeConnectionTypes.Main, index: 1 }], + ], + }, + [NODE_NAMES.MERGE]: { [NodeConnectionTypes.Main]: [ [{ node: NODE_NAMES.REPLY_AGENT, type: NodeConnectionTypes.Main, index: 0 }], ], @@ -271,25 +322,12 @@ export class ChatHubWorkflowService { }, {}), }; - const nodeExecutionStack: IExecuteData[] = [ - { - node: chatTriggerNode, - data: { - main: [ - [ - { - json: { - sessionId, - action: 'sendMessage', - chatInput: humanMessage, - }, - }, - ], - ], - }, - source: null, - }, - ]; + const nodeExecutionStack = this.prepareExecutionData( + chatTriggerNode, + sessionId, + humanMessage, + attachments, + ); const executionData = createRunExecutionData({ executionData: { @@ -452,7 +490,7 @@ export class ChatHubWorkflowService { return { ...common, parameters: { - model: { __rl: true, mode: 'id', value: model }, + model, options: {}, }, }; @@ -465,6 +503,33 @@ export class ChatHubWorkflowService { }, }; } + case 'awsBedrock': { + return { + ...common, + parameters: { + model, + options: {}, + }, + }; + } + case 'cohere': { + return { + ...common, + parameters: { + model, + options: {}, + }, + }; + } + case 'mistralCloud': { + return { + ...common, + parameters: { + model, + options: {}, + }, + }; + } default: throw new OperationalError('Unsupported model provider'); } @@ -532,6 +597,22 @@ export class ChatHubWorkflowService { }; } + private buildMergeNode(): INode { + return { + parameters: { + mode: 'combine', + fieldsToMatchString: 'chatInput', + joinMode: 'enrichInput1', + options: {}, + }, + type: MERGE_NODE_TYPE, + typeVersion: 3.2, + position: [224, -100], + id: uuidv4(), + name: NODE_NAMES.MERGE, + }; + } + private buildTitleGeneratorAgentNode(): INode { return { parameters: { diff --git a/packages/cli/src/modules/chat-hub/chat-hub.attachment.service.ts b/packages/cli/src/modules/chat-hub/chat-hub.attachment.service.ts new file mode 100644 index 00000000000..9a4d724786e --- /dev/null +++ b/packages/cli/src/modules/chat-hub/chat-hub.attachment.service.ts @@ -0,0 +1,160 @@ +import { Service } from '@n8n/di'; +import { BINARY_ENCODING, type IBinaryData } from 'n8n-workflow'; +import { sanitizeFilename } from '@n8n/utils'; +import { BinaryDataService, FileLocation } from 'n8n-core'; +import { Not, IsNull } from '@n8n/typeorm'; +import { ChatHubMessageRepository } from './chat-message.repository'; +import type { ChatMessageId, ChatSessionId, ChatAttachment } from '@n8n/api-types'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import type Stream from 'node:stream'; +import FileType from 'file-type'; + +@Service() +export class ChatHubAttachmentService { + private readonly maxTotalSizeBytes = 200 * 1024 * 1024; // 200 MB + + constructor( + private readonly binaryDataService: BinaryDataService, + private readonly messageRepository: ChatHubMessageRepository, + ) {} + + /** + * Stores attachments through BinaryDataService. + * This populates the 'id' and other metadata for attachments. When external storage is used, + * BinaryDataService replaces base64 data with the storage mode string (e.g., "filesystem-v2"). + */ + async store( + sessionId: ChatSessionId, + messageId: ChatMessageId, + attachments: ChatAttachment[], + ): Promise { + let totalSize = 0; + const storedAttachments: IBinaryData[] = []; + + for (const attachment of attachments) { + const buffer = Buffer.from(attachment.data, BINARY_ENCODING); + totalSize += buffer.length; + + if (totalSize > this.maxTotalSizeBytes) { + const maxSizeMB = Math.floor(this.maxTotalSizeBytes / (1024 * 1024)); + + throw new BadRequestError( + `Total size of attachments exceeds maximum size of ${maxSizeMB} MB`, + ); + } + + const stored = await this.processAttachment(sessionId, messageId, attachment, buffer); + storedAttachments.push(stored); + } + + return storedAttachments; + } + + /* + * Gets a specific attachment from a message by index and returns it as either buffer or stream + */ + async getAttachment( + sessionId: ChatSessionId, + messageId: ChatMessageId, + attachmentIndex: number, + ): Promise< + [ + IBinaryData, + ( + | { type: 'buffer'; buffer: Buffer; fileSize: number } + | { type: 'stream'; stream: Stream.Readable; fileSize: number } + ), + ] + > { + const message = await this.messageRepository.getOneById(messageId, sessionId, []); + + if (!message) { + throw new NotFoundError('Message not found'); + } + + const attachment = message.attachments?.[attachmentIndex]; + + if (!attachment) { + throw new NotFoundError('Attachment not found'); + } + + if (attachment.id) { + const metadata = await this.binaryDataService.getMetadata(attachment.id); + const stream = await this.binaryDataService.getAsStream(attachment.id); + + return [attachment, { type: 'stream', stream, fileSize: metadata.fileSize }]; + } + + if (attachment.data) { + const buffer = await this.binaryDataService.getAsBuffer(attachment); + + return [attachment, { type: 'buffer', buffer, fileSize: buffer.length }]; + } + + throw new NotFoundError('Attachment has no stored file'); + } + + /** + * Deletes all files attached to messages in the session + */ + async deleteAllBySessionId(sessionId: string): Promise { + const messages = await this.messageRepository.getManyBySessionId(sessionId); + + await this.deleteAttachments(messages.flatMap((message) => message.attachments ?? [])); + } + + /** + * Deletes all chat attachment files. + */ + async deleteAll(): Promise { + const messages = await this.messageRepository.find({ + where: { + attachments: Not(IsNull()), + }, + select: ['attachments'], + }); + + await this.deleteAttachments(messages.flatMap((message) => message.attachments ?? [])); + } + + /** + * Deletes attachments by their binary data directly (used for rollback when message wasn't saved) + */ + async deleteAttachments(attachments: IBinaryData[]): Promise { + await this.binaryDataService.deleteManyByBinaryDataId( + attachments.flatMap((attachment) => (attachment.id ? [attachment.id] : [])), + ); + } + + /** + * Processes a single attachment by populating metadata and storing it. + */ + private async processAttachment( + sessionId: ChatSessionId, + messageId: ChatMessageId, + attachment: ChatAttachment, + buffer: Buffer, + ): Promise { + const sanitizedFileName = sanitizeFilename(attachment.fileName); + const fileTypeData = await FileType.fromBuffer(buffer); + + // Only trust content-based detection for security + const mimeType = fileTypeData?.mime ?? 'application/octet-stream'; + + // Construct IBinaryData with all required fields + const binaryData: IBinaryData = { + data: attachment.data, + mimeType, + fileName: sanitizedFileName, + fileSize: `${buffer.length}`, + fileExtension: fileTypeData?.ext, + }; + + return await this.binaryDataService.store( + FileLocation.ofChatHubMessageAttachment(sessionId, messageId), + buffer, + binaryData, + ); + } +} diff --git a/packages/cli/src/modules/chat-hub/chat-hub.constants.ts b/packages/cli/src/modules/chat-hub/chat-hub.constants.ts index 51a362d4847..1baffd57120 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.constants.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.constants.ts @@ -32,6 +32,18 @@ export const PROVIDER_NODE_TYPE_MAP: Record { - return await this.chatService.getConversations(req.user.id); + return await this.chatService.getConversations(req.user.id, query.limit, query.cursor); } @Get('/conversations/:sessionId') @@ -69,6 +76,49 @@ export class ChatHubController { return await this.chatService.getConversation(req.user.id, sessionId); } + @Get('/conversations/:sessionId/messages/:messageId/attachments/:index') + @GlobalScope('chatHub:message') + async getMessageAttachment( + req: AuthenticatedRequest, + res: Response, + @Param('sessionId') sessionId: ChatSessionId, + @Param('messageId') messageId: ChatMessageId, + @Param('index') index: string, + ) { + const attachmentIndex = Number.parseInt(index, 10); + + if (isNaN(attachmentIndex)) { + throw new BadRequestError('Invalid attachment index'); + } + + // Verify user has access to this session + await this.chatService.getConversation(req.user.id, sessionId); + + const [{ mimeType, fileName }, attachmentAsStreamOrBuffer] = + await this.chatAttachmentService.getAttachment(sessionId, messageId, attachmentIndex); + + res.setHeader('Content-Type', mimeType ?? 'application/octet-stream'); + + if (attachmentAsStreamOrBuffer.fileSize) { + res.setHeader('Content-Length', attachmentAsStreamOrBuffer.fileSize); + } + + if (fileName) { + res.setHeader('Content-Disposition', `attachment; filename="${sanitizeFilename(fileName)}"`); + } + + if (attachmentAsStreamOrBuffer.type === 'buffer') { + res.send(attachmentAsStreamOrBuffer.buffer); + return; + } + + return await new Promise((resolve, reject) => { + attachmentAsStreamOrBuffer.stream.on('end', resolve); + attachmentAsStreamOrBuffer.stream.on('error', reject); + attachmentAsStreamOrBuffer.stream.pipe(res); + }); + } + @GlobalScope('chatHub:message') @Post('/conversations/send') async sendMessage( diff --git a/packages/cli/src/modules/chat-hub/chat-hub.service.ts b/packages/cli/src/modules/chat-hub/chat-hub.service.ts index 7c290ccfb10..3be33d184bd 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.service.ts @@ -33,10 +33,10 @@ import { jsonParse, StructuredChunk, RESPOND_TO_CHAT_NODE_TYPE, - IExecuteData, IRunExecutionData, INodeParameters, INode, + type IBinaryData, createRunExecutionData, } from 'n8n-workflow'; @@ -45,10 +45,11 @@ import { ChatHubCredentialsService, CredentialWithProjectId } from './chat-hub-c import type { ChatHubMessage } from './chat-hub-message.entity'; import { ChatHubWorkflowService } from './chat-hub-workflow.service'; import { JSONL_STREAM_HEADERS, NODE_NAMES, PROVIDER_NODE_TYPE_MAP } from './chat-hub.constants'; -import type { +import { HumanMessagePayload, RegenerateMessagePayload, EditMessagePayload, + validChatTriggerParamsShape, } from './chat-hub.types'; import { ChatHubMessageRepository } from './chat-message.repository'; import { ChatHubSessionRepository } from './chat-session.repository'; @@ -64,6 +65,7 @@ import { getBase } from '@/workflow-execute-additional-data'; import { WorkflowExecutionService } from '@/workflows/workflow-execution.service'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { WorkflowService } from '@/workflows/workflow.service'; +import { ChatHubAttachmentService } from './chat-hub.attachment.service'; @Service() export class ChatHubService { @@ -83,6 +85,7 @@ export class ChatHubService { private readonly chatHubAgentService: ChatHubAgentService, private readonly chatHubCredentialsService: ChatHubCredentialsService, private readonly chatHubWorkflowService: ChatHubWorkflowService, + private readonly chatHubAttachmentService: ChatHubAttachmentService, ) {} async getModels( @@ -159,6 +162,12 @@ export class ChatHubService { return await this.fetchOllamaModels(credentials, additionalData); case 'azureOpenAi': return await this.fetchAzureOpenAiModels(credentials, additionalData); + case 'awsBedrock': + return await this.fetchAwsBedrockModels(credentials, additionalData); + case 'cohere': + return await this.fetchCohereModels(credentials, additionalData); + case 'mistralCloud': + return await this.fetchMistralCloudModels(credentials, additionalData); case 'n8n': return await this.fetchAgentWorkflowsAsModels(user); case 'custom-agent': @@ -189,6 +198,7 @@ export class ChatHubService { }, createdAt: null, updatedAt: null, + allowFileUploads: true, })), }; } @@ -216,6 +226,7 @@ export class ChatHubService { }, createdAt: null, updatedAt: null, + allowFileUploads: true, })), }; } @@ -273,7 +284,7 @@ export class ChatHubService { return { models: results.map((result) => ({ - name: String(result.value), + name: result.name, description: result.description ?? null, model: { provider: 'google', @@ -281,6 +292,7 @@ export class ChatHubService { }, createdAt: null, updatedAt: null, + allowFileUploads: true, })), }; } @@ -331,7 +343,7 @@ export class ChatHubService { return { models: results.map((result) => ({ - name: String(result.value), + name: result.name, description: result.description ?? null, model: { provider: 'ollama', @@ -339,6 +351,7 @@ export class ChatHubService { }, createdAt: null, updatedAt: null, + allowFileUploads: true, })), }; } @@ -355,6 +368,231 @@ export class ChatHubService { }; } + private async fetchAwsBedrockModels( + credentials: INodeCredentials, + additionalData: IWorkflowExecuteAdditionalData, + ): Promise { + // From AWS Bedrock node + // https://github.com/n8n-io/n8n/blob/master/packages/%40n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts#L100 + // https://github.com/n8n-io/n8n/blob/master/packages/%40n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts#L155 + const foundationModelsRequest = this.nodeParametersService.getOptionsViaLoadOptions( + { + routing: { + request: { + method: 'GET', + url: '/foundation-models?&byOutputModality=TEXT&byInferenceType=ON_DEMAND', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'modelSummaries', + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{$responseItem.modelName}}', + description: '={{$responseItem.modelArn}}', + value: '={{$responseItem.modelId}}', + }, + }, + { + type: 'sort', + properties: { + key: 'name', + }, + }, + ], + }, + }, + }, + additionalData, + PROVIDER_NODE_TYPE_MAP.awsBedrock, + {}, + credentials, + ); + + const inferenceProfileModelsRequest = this.nodeParametersService.getOptionsViaLoadOptions( + { + routing: { + request: { + method: 'GET', + url: '/inference-profiles?maxResults=1000', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'inferenceProfileSummaries', + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{$responseItem.inferenceProfileName}}', + description: + '={{$responseItem.description || $responseItem.inferenceProfileArn}}', + value: '={{$responseItem.inferenceProfileId}}', + }, + }, + { + type: 'sort', + properties: { + key: 'name', + }, + }, + ], + }, + }, + }, + additionalData, + PROVIDER_NODE_TYPE_MAP.awsBedrock, + {}, + credentials, + ); + + const [foundationModels, inferenceProfileModels] = await Promise.all([ + foundationModelsRequest, + inferenceProfileModelsRequest, + ]); + + return { + models: foundationModels.concat(inferenceProfileModels).map((result) => ({ + name: result.name, + description: result.description ?? String(result.value), + model: { + provider: 'awsBedrock', + model: String(result.value), + }, + createdAt: null, + updatedAt: null, + allowFileUploads: true, + })), + }; + } + + private async fetchMistralCloudModels( + credentials: INodeCredentials, + additionalData: IWorkflowExecuteAdditionalData, + ): Promise { + const results = await this.nodeParametersService.getOptionsViaLoadOptions( + { + routing: { + request: { + method: 'GET', + url: '/models', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'data', + }, + }, + { + type: 'filter', + properties: { + pass: "={{ !$responseItem.id.includes('embed') }}", + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{ $responseItem.id }}', + value: '={{ $responseItem.id }}', + }, + }, + { + type: 'sort', + properties: { + key: 'name', + }, + }, + ], + }, + }, + }, + additionalData, + PROVIDER_NODE_TYPE_MAP.mistralCloud, + {}, + credentials, + ); + + return { + models: results.map((result) => ({ + name: result.name, + description: result.description ?? String(result.value), + model: { + provider: 'mistralCloud', + model: String(result.value), + }, + createdAt: null, + updatedAt: null, + })), + }; + } + + private async fetchCohereModels( + credentials: INodeCredentials, + additionalData: IWorkflowExecuteAdditionalData, + ): Promise { + const results = await this.nodeParametersService.getOptionsViaLoadOptions( + { + routing: { + request: { + method: 'GET', + url: '/v1/models?page_size=100&endpoint=chat', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'models', + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{$responseItem.name}}', + value: '={{$responseItem.name}}', + description: '={{$responseItem.description}}', + }, + }, + { + type: 'sort', + properties: { + key: 'name', + }, + }, + ], + }, + }, + }, + additionalData, + PROVIDER_NODE_TYPE_MAP.cohere, + {}, + credentials, + ); + + return { + models: results.map((result) => ({ + name: result.name, + description: result.description ?? null, + model: { + provider: 'cohere', + model: String(result.value), + }, + createdAt: null, + updatedAt: null, + })), + }; + } + private async fetchAgentWorkflowsAsModels(user: User): Promise { const nodeTypes = [CHAT_TRIGGER_NODE_TYPE]; const workflows = await this.workflowService.getWorkflowsWithNodesIncluded( @@ -367,37 +605,32 @@ export class ChatHubService { models: workflows // Ensure the user has at least read access to the workflow .filter((workflow) => workflow.scopes.includes('workflow:read')) - .filter((workflow) => workflow.active) + .filter((workflow) => !!workflow.activeVersionId) .flatMap((workflow) => { const chatTrigger = workflow.nodes?.find((node) => node.type === CHAT_TRIGGER_NODE_TYPE); if (!chatTrigger) { return []; } - if (chatTrigger.parameters.availableInChat !== true) { + const chatTriggerParams = validChatTriggerParamsShape.safeParse( + chatTrigger.parameters, + ).data; + + if (!chatTriggerParams) { return []; } - const name = - typeof chatTrigger.parameters.agentName === 'string' && - chatTrigger.parameters.agentName.length > 0 - ? chatTrigger.parameters.agentName - : workflow.name; - return [ { - name: name ?? 'Unknown Agent', - description: - typeof chatTrigger.parameters.agentDescription === 'string' && - chatTrigger.parameters.agentDescription.length > 0 - ? chatTrigger.parameters.agentDescription - : null, + name: chatTriggerParams.agentName ?? workflow.name ?? 'Unknown Agent', + description: chatTriggerParams.agentDescription ?? null, model: { provider: 'n8n', workflowId: workflow.id, }, createdAt: workflow.createdAt ? workflow.createdAt.toISOString() : null, updatedAt: workflow.updatedAt ? workflow.updatedAt.toISOString() : null, + allowFileUploads: chatTriggerParams.options?.allowFileUploads ?? false, }, ]; }), @@ -449,12 +682,31 @@ export class ChatHubService { } async sendHumanMessage(res: Response, user: User, payload: HumanMessagePayload) { - const { sessionId, messageId, message, model, credentials, previousMessageId, tools } = payload; + const { + sessionId, + messageId, + message, + model, + credentials, + previousMessageId, + tools, + attachments, + } = payload; const credentialId = this.getModelCredential(model, credentials); - const { executionData, workflowData } = await this.messageRepository.manager.transaction( - async (trx) => { + // Store attachments early to populate 'id' field via BinaryDataService + const processedAttachments = await this.chatHubAttachmentService.store( + sessionId, + messageId, + attachments, + ); + + let executionData: IRunExecutionData; + let workflowData: IWorkflowBase; + + try { + const result = await this.messageRepository.manager.transaction(async (trx) => { let session = await this.getChatSession(user, sessionId, trx); session ??= await this.createChatSession(user, sessionId, model, credentialId, tools, trx); @@ -462,36 +714,36 @@ export class ChatHubService { const messages = Object.fromEntries((session.messages ?? []).map((m) => [m.id, m])); const history = this.buildMessageHistory(messages, previousMessageId); - await this.saveHumanMessage(payload, user, previousMessageId, model, undefined, trx); + await this.saveHumanMessage( + payload, + processedAttachments, + user, + previousMessageId, + model, + undefined, + trx, + ); - if (model.provider === 'n8n') { - return await this.prepareCustomAgentWorkflow(user, sessionId, model.workflowId, message); - } - - if (model.provider === 'custom-agent') { - return await this.prepareChatAgentWorkflow( - model.agentId, - user, - sessionId, - history, - message, - trx, - ); - } - - return await this.prepareBaseChatWorkflow( + return await this.prepareReplyWorkflow( user, sessionId, credentials, model, history, message, - undefined, - session.tools, + tools, + processedAttachments, trx, ); - }, - ); + }); + + executionData = result.executionData; + workflowData = result.workflowData; + } catch (error) { + // Rollback stored attachments if transaction fails + await this.chatHubAttachmentService.deleteAttachments(processedAttachments); + throw error; + } await this.executeChatWorkflowWithCleanup( res, @@ -525,10 +777,6 @@ export class ChatHubService { const messageToEdit = await this.getChatMessage(session.id, editId, [], trx); - if (!['ai', 'human'].includes(messageToEdit.type)) { - throw new BadRequestError('Only human and AI messages can be edited'); - } - if (messageToEdit.type === 'ai') { // AI edits just change the original message without revisioning or response generation await this.messageRepository.updateChatMessage(editId, { content: payload.message }, trx); @@ -542,8 +790,12 @@ export class ChatHubService { // If the message to edit isn't the original message, we want to point to the original message const revisionOfMessageId = messageToEdit.revisionOfMessageId ?? messageToEdit.id; + // Attachments are already processed (from the original message) + const attachments = messageToEdit.attachments ?? []; + await this.saveHumanMessage( payload, + attachments, user, messageToEdit.previousMessageId, model, @@ -551,34 +803,20 @@ export class ChatHubService { trx, ); - if (model.provider === 'n8n') { - return await this.prepareCustomAgentWorkflow(user, sessionId, model.workflowId, message); - } - - if (model.provider === 'custom-agent') { - return await this.prepareChatAgentWorkflow( - model.agentId, - user, - sessionId, - history, - message, - trx, - ); - } - - return await this.prepareBaseChatWorkflow( + return await this.prepareReplyWorkflow( user, sessionId, credentials, model, history, message, - undefined, session.tools, + attachments, trx, ); } - return null; + + throw new BadRequestError('Only human and AI messages can be edited'); }); if (!workflow) { @@ -600,7 +838,6 @@ export class ChatHubService { async regenerateAIMessage(res: Response, user: User, payload: RegenerateMessagePayload) { const { sessionId, retryId, model, credentials } = payload; - const { provider } = model; const { workflow: { workflowData, executionData }, @@ -637,37 +874,18 @@ export class ChatHubService { // If the message being retried is itself a retry, we want to point to the original message const retryOfMessageId = messageToRetry.retryOfMessageId ?? messageToRetry.id; const message = lastHumanMessage ? lastHumanMessage.content : ''; - - let workflow; - if (provider === 'n8n') { - workflow = await this.prepareCustomAgentWorkflow( - user, - sessionId, - model.workflowId, - message, - ); - } else if (provider === 'custom-agent') { - workflow = await this.prepareChatAgentWorkflow( - model.agentId, - user, - sessionId, - history, - message, - trx, - ); - } else { - workflow = await this.prepareBaseChatWorkflow( - user, - sessionId, - credentials, - model, - history, - message, - undefined, - session.tools, - trx, - ); - } + const attachments = lastHumanMessage.attachments ?? []; + const workflow = await this.prepareReplyWorkflow( + user, + sessionId, + credentials, + model, + history, + message, + session.tools, + attachments, + trx, + ); return { workflow, @@ -688,6 +906,53 @@ export class ChatHubService { ); } + private async prepareReplyWorkflow( + user: User, + sessionId: ChatSessionId, + credentials: INodeCredentials, + model: ChatHubConversationModel, + history: ChatHubMessage[], + message: string, + tools: INode[], + attachments: IBinaryData[], + trx: EntityManager, + ) { + if (model.provider === 'n8n') { + return await this.prepareCustomAgentWorkflow( + user, + sessionId, + model.workflowId, + message, + attachments, + ); + } + + if (model.provider === 'custom-agent') { + return await this.prepareChatAgentWorkflow( + model.agentId, + user, + sessionId, + history, + message, + attachments, + trx, + ); + } + + return await this.prepareBaseChatWorkflow( + user, + sessionId, + credentials, + model, + history, + message, + undefined, + tools, + attachments, + trx, + ); + } + private async prepareBaseChatWorkflow( user: User, sessionId: ChatSessionId, @@ -697,6 +962,7 @@ export class ChatHubService { message: string, systemMessage: string | undefined, tools: INode[], + attachments: IBinaryData[], trx: EntityManager, ) { const credential = await this.chatHubCredentialsService.ensureCredentials( @@ -712,6 +978,7 @@ export class ChatHubService { credential.projectId, history, message, + attachments, credentials, model, systemMessage, @@ -726,6 +993,7 @@ export class ChatHubService { sessionId: ChatSessionId, history: ChatHubMessage[], message: string, + attachments: IBinaryData[], trx: EntityManager, ) { const agent = await this.chatHubAgentService.getAgentById(agentId, user.id); @@ -772,6 +1040,7 @@ export class ChatHubService { message, systemMessage, tools, + attachments, trx, ); } @@ -781,6 +1050,7 @@ export class ChatHubService { sessionId: ChatSessionId, workflowId: string, message: string, + attachments: IBinaryData[], ) { const workflowEntity = await this.workflowFinderService.findWorkflowForUser( workflowId, @@ -813,25 +1083,12 @@ export class ChatHubService { ); } - const nodeExecutionStack: IExecuteData[] = [ - { - node: chatTriggerNode, - data: { - main: [ - [ - { - json: { - sessionId, - action: 'sendMessage', - chatInput: message, - }, - }, - ], - ], - }, - source: null, - }, - ]; + const nodeExecutionStack = this.chatHubWorkflowService.prepareExecutionData( + chatTriggerNode, + sessionId, + message, + attachments, + ); const executionData = createRunExecutionData({ executionData: { @@ -1341,6 +1598,7 @@ export class ChatHubService { private async saveHumanMessage( payload: HumanMessagePayload | EditMessagePayload, + attachments: IBinaryData[], user: User, previousMessageId: ChatMessageId | null, model: ChatHubConversationModel, @@ -1357,6 +1615,7 @@ export class ChatHubService { previousMessageId, revisionOfMessageId, name: user.firstName || 'User', + attachments, ...model, }, trx, @@ -1483,24 +1742,36 @@ export class ChatHubService { /** * Get all conversations for a user */ - async getConversations(userId: string): Promise { - const sessions = await this.sessionRepository.getManyByUserId(userId); + async getConversations( + userId: string, + limit: number, + cursor?: string, + ): Promise { + const sessions = await this.sessionRepository.getManyByUserId(userId, limit + 1, cursor); - return sessions.map((session) => ({ - id: session.id, - title: session.title, - ownerId: session.ownerId, - lastMessageAt: session.lastMessageAt?.toISOString() ?? null, - credentialId: session.credentialId, - provider: session.provider, - model: session.model, - workflowId: session.workflowId, - agentId: session.agentId, - agentName: session.agentName, - createdAt: session.createdAt.toISOString(), - updatedAt: session.updatedAt.toISOString(), - tools: session.tools, - })); + const hasMore = sessions.length > limit; + const data = hasMore ? sessions.slice(0, limit) : sessions; + const nextCursor = hasMore ? data[data.length - 1].id : null; + + return { + data: data.map((session) => ({ + id: session.id, + title: session.title, + ownerId: session.ownerId, + lastMessageAt: session.lastMessageAt?.toISOString() ?? null, + credentialId: session.credentialId, + provider: session.provider, + model: session.model, + workflowId: session.workflowId, + agentId: session.agentId, + agentName: session.agentName, + createdAt: session.createdAt.toISOString(), + updatedAt: session.updatedAt.toISOString(), + tools: session.tools, + })), + nextCursor, + hasMore, + }; } /** @@ -1555,6 +1826,11 @@ export class ChatHubService { previousMessageId: message.previousMessageId, retryOfMessageId: message.retryOfMessageId, revisionOfMessageId: message.revisionOfMessageId, + + attachments: (message.attachments ?? []).map(({ fileName, mimeType }) => ({ + fileName, + mimeType, + })), }; } @@ -1583,6 +1859,7 @@ export class ChatHubService { } async deleteAllSessions() { + await this.chatHubAttachmentService.deleteAll(); const result = await this.sessionRepository.deleteAll(); return result; } @@ -1690,6 +1967,7 @@ export class ChatHubService { throw new NotFoundError('Session not found'); } + await this.chatHubAttachmentService.deleteAllBySessionId(sessionId); await this.sessionRepository.deleteChatHubSession(sessionId); } } diff --git a/packages/cli/src/modules/chat-hub/chat-hub.types.ts b/packages/cli/src/modules/chat-hub/chat-hub.types.ts index c8c85e68c64..b6b6c5d7152 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.types.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.types.ts @@ -1,5 +1,21 @@ -import type { ChatHubConversationModel, ChatMessageId, ChatSessionId } from '@n8n/api-types'; +import type { + ChatHubConversationModel, + ChatHubProvider, + ChatMessageId, + ChatSessionId, + ChatAttachment, +} from '@n8n/api-types'; import type { INode, INodeCredentials } from 'n8n-workflow'; +import { z } from 'zod'; + +export interface ModelWithCredentials { + provider: ChatHubProvider; + model?: string; + workflowId?: string; + credentialId: string | null; + agentId?: string; + name?: string; +} export interface BaseMessagePayload { userId: string; @@ -12,6 +28,7 @@ export interface HumanMessagePayload extends BaseMessagePayload { messageId: ChatMessageId; message: string; previousMessageId: ChatMessageId | null; + attachments: ChatAttachment[]; tools: INode[]; } export interface RegenerateMessagePayload extends BaseMessagePayload { @@ -31,3 +48,14 @@ export interface MessageRecord { message: string; hideFromUI: boolean; } + +export const validChatTriggerParamsShape = z.object({ + availableInChat: z.literal(true), + agentName: z.string().min(1).optional(), + agentDescription: z.string().min(1).optional(), + options: z + .object({ + allowFileUploads: z.boolean().optional(), + }) + .optional(), +}); diff --git a/packages/cli/src/modules/chat-hub/chat-message.repository.ts b/packages/cli/src/modules/chat-hub/chat-message.repository.ts index 9b86ba01e9b..eab1bd09bd5 100644 --- a/packages/cli/src/modules/chat-hub/chat-message.repository.ts +++ b/packages/cli/src/modules/chat-hub/chat-message.repository.ts @@ -5,6 +5,7 @@ import { DataSource, EntityManager, Repository } from '@n8n/typeorm'; import { ChatHubMessage } from './chat-hub-message.entity'; import { ChatHubSessionRepository } from './chat-session.repository'; +import type { IBinaryData } from 'n8n-workflow'; @Service() export class ChatHubMessageRepository extends Repository { @@ -33,7 +34,7 @@ export class ChatHubMessageRepository extends Repository { async updateChatMessage( id: ChatMessageId, - fields: { status?: ChatHubMessageStatus; content?: string }, + fields: { status?: ChatHubMessageStatus; content?: string; attachments?: IBinaryData[] }, trx?: EntityManager, ) { return await withTransaction( diff --git a/packages/cli/src/modules/chat-hub/chat-session.repository.ts b/packages/cli/src/modules/chat-hub/chat-session.repository.ts index b216dc9b310..3fa513cbfe2 100644 --- a/packages/cli/src/modules/chat-hub/chat-session.repository.ts +++ b/packages/cli/src/modules/chat-hub/chat-session.repository.ts @@ -2,6 +2,8 @@ import { withTransaction } from '@n8n/db'; import { Service } from '@n8n/di'; import { DataSource, EntityManager, Repository } from '@n8n/typeorm'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; + import { ChatHubSession } from './chat-hub-session.entity'; @Service() @@ -56,11 +58,33 @@ export class ChatHubSessionRepository extends Repository { }); } - async getManyByUserId(userId: string) { - return await this.find({ - where: { ownerId: userId }, - order: { lastMessageAt: 'DESC', id: 'ASC' }, - }); + async getManyByUserId(userId: string, limit: number, cursor?: string) { + const queryBuilder = this.createQueryBuilder('session') + .where('session.ownerId = :userId', { userId }) + .orderBy("COALESCE(session.lastMessageAt, '1970-01-01')", 'DESC') + .addOrderBy('session.id', 'ASC'); + + if (cursor) { + const cursorSession = await this.findOne({ + where: { id: cursor, ownerId: userId }, + }); + + if (!cursorSession) { + throw new NotFoundError('Cursor session not found'); + } + + queryBuilder.andWhere( + '(session.lastMessageAt < :lastMessageAt OR (session.lastMessageAt = :lastMessageAt AND session.id > :id))', + { + lastMessageAt: cursorSession.lastMessageAt, + id: cursorSession.id, + }, + ); + } + + queryBuilder.take(limit); + + return await queryBuilder.getMany(); } async getOneById(id: string, userId: string, trx?: EntityManager) { diff --git a/packages/cli/src/modules/chat-hub/context-limits.ts b/packages/cli/src/modules/chat-hub/context-limits.ts index ca3fde834c4..64953d0b4d7 100644 --- a/packages/cli/src/modules/chat-hub/context-limits.ts +++ b/packages/cli/src/modules/chat-hub/context-limits.ts @@ -139,6 +139,9 @@ export const maxContextWindowTokens: Record { let insightsModule: InsightsModule; @@ -23,11 +24,23 @@ describe('InsightsModule', () => { beforeEach(async () => { jest.clearAllMocks(); await testDb.truncate(['Project']); - insightsModule = Container.get(InsightsModule); + mockInstanceSettings = mock(); Container.set(InstanceSettings, mockInstanceSettings); Container.set(Logger, mockLogger()); Container.set(LicenseState, mock()); + Container.set( + InsightsService, + new InsightsService( + mock(), + mock(), + mock(), + Container.get(LicenseState), + mockInstanceSettings, + Container.get(Logger), + ), + ); + insightsModule = Container.get(InsightsModule); await createTeamProject(); }); diff --git a/packages/cli/src/modules/insights/insights.module.ts b/packages/cli/src/modules/insights/insights.module.ts index af495da88d4..3ba880b2b19 100644 --- a/packages/cli/src/modules/insights/insights.module.ts +++ b/packages/cli/src/modules/insights/insights.module.ts @@ -1,17 +1,14 @@ import type { ModuleInterface } from '@n8n/decorators'; import { BackendModule, OnShutdown } from '@n8n/decorators'; import { Container } from '@n8n/di'; -import { InstanceSettings } from 'n8n-core'; -@BackendModule({ name: 'insights' }) +/** + * Only main- and webhook-type instances collect insights because + * only they are informed of finished workflow executions. + */ +@BackendModule({ name: 'insights', instanceTypes: ['main', 'webhook'] }) export class InsightsModule implements ModuleInterface { async init() { - /** - * Only main- and webhook-type instances collect insights because - * only they are informed of finished workflow executions. - */ - if (Container.get(InstanceSettings).instanceType === 'worker') return; - await import('./insights.controller'); const { InsightsService } = await import('./insights.service'); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts index 25ce5354307..bdd8dc26d90 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts @@ -203,6 +203,7 @@ describe('McpSettingsController', () => { const entity = new WorkflowEntity(); entity.id = workflowId; entity.active = true; + entity.activeVersionId = overrides.active === false ? null : 'current-version-id'; entity.nodes = [createWebhookNode()]; entity.settings = { saveManualExecutions: true }; entity.versionId = 'current-version-id'; diff --git a/packages/cli/src/modules/mcp/__tests__/mock.utils.ts b/packages/cli/src/modules/mcp/__tests__/mock.utils.ts index cd2eb05d8c8..7364e8cb54c 100644 --- a/packages/cli/src/modules/mcp/__tests__/mock.utils.ts +++ b/packages/cli/src/modules/mcp/__tests__/mock.utils.ts @@ -27,6 +27,8 @@ export const createWorkflow = (overrides: Partial = {}) => ({ }, ], active: overrides.active ?? false, + versionId: 'some-version-id', + activeVersionId: overrides.active ? 'some-version-id' : null, isArchived: overrides.isArchived ?? false, createdAt: overrides.createdAt ?? new Date('2024-01-01T00:00:00.000Z'), updatedAt: overrides.updatedAt ?? new Date('2024-01-02T00:00:00.000Z'), diff --git a/packages/cli/src/modules/mcp/__tests__/search-workflows.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/search-workflows.tool.test.ts index 8a2cfb29f11..ab34340c708 100644 --- a/packages/cli/src/modules/mcp/__tests__/search-workflows.tool.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/search-workflows.tool.test.ts @@ -73,6 +73,7 @@ describe('search-workflows MCP tool', () => { id: 'a', name: 'Alpha', active: false, + activeVersionId: null, createdAt: new Date('2024-01-01T00:00:00.000Z').toISOString(), updatedAt: new Date('2024-01-02T00:00:00.000Z').toISOString(), triggerCount: 1, @@ -82,6 +83,7 @@ describe('search-workflows MCP tool', () => { id: 'b', name: 'Beta', active: true, + activeVersionId: workflows[1].versionId, createdAt: new Date('2024-01-01T00:00:00.000Z').toISOString(), updatedAt: new Date('2024-01-02T00:00:00.000Z').toISOString(), triggerCount: 1, diff --git a/packages/cli/src/modules/mcp/mcp.settings.controller.ts b/packages/cli/src/modules/mcp/mcp.settings.controller.ts index db20f5fa59d..72018814d10 100644 --- a/packages/cli/src/modules/mcp/mcp.settings.controller.ts +++ b/packages/cli/src/modules/mcp/mcp.settings.controller.ts @@ -1,6 +1,15 @@ import { ModuleRegistry, Logger } from '@n8n/backend-common'; import { type AuthenticatedRequest, WorkflowEntity } from '@n8n/db'; -import { Body, Post, Get, Patch, RestController, GlobalScope, Param } from '@n8n/decorators'; +import { + Body, + Post, + Get, + Patch, + RestController, + GlobalScope, + Param, + ProjectScope, +} from '@n8n/decorators'; import type { Response } from 'express'; import { UpdateMcpSettingsDto } from './dto/update-mcp-settings.dto'; @@ -57,7 +66,7 @@ export class McpSettingsController { return await this.mcpServerApiKeyService.rotateMcpServerApiKey(req.user); } - @GlobalScope('mcp:manage') + @ProjectScope('workflow:update') @Patch('/workflows/:workflowId/toggle-access') async toggleWorkflowMCPAccess( req: AuthenticatedRequest, @@ -79,7 +88,7 @@ export class McpSettingsController { ); } - if (!workflow.active && dto.availableInMCP) { + if (!workflow.activeVersionId && dto.availableInMCP) { throw new BadRequestError('MCP access can only be set for active workflows'); } diff --git a/packages/cli/src/modules/mcp/tools/get-workflow-details.tool.ts b/packages/cli/src/modules/mcp/tools/get-workflow-details.tool.ts index eeb1e3ce5be..7703cb34a4f 100644 --- a/packages/cli/src/modules/mcp/tools/get-workflow-details.tool.ts +++ b/packages/cli/src/modules/mcp/tools/get-workflow-details.tool.ts @@ -126,7 +126,7 @@ export async function getWorkflowDetails( const sanitizedWorkflow: WorkflowDetailsResult['workflow'] = { id: workflow.id, name: workflow.name, - active: workflow.active, + active: workflow.activeVersionId !== null, isArchived: workflow.isArchived, versionId: workflow.versionId, triggerCount: workflow.triggerCount, diff --git a/packages/cli/src/modules/mcp/tools/search-workflows.tool.ts b/packages/cli/src/modules/mcp/tools/search-workflows.tool.ts index 05dfd7bc253..39cd87fb28f 100644 --- a/packages/cli/src/modules/mcp/tools/search-workflows.tool.ts +++ b/packages/cli/src/modules/mcp/tools/search-workflows.tool.ts @@ -165,11 +165,22 @@ export async function searchWorkflows( ); const formattedWorkflows: SearchWorkflowsItem[] = (workflows as WorkflowEntity[]).map( - ({ id, name, description, active, createdAt, updatedAt, triggerCount, nodes }) => ({ + ({ id, name, description, active, + activeVersionId, + createdAt, + updatedAt, + triggerCount, + nodes, + }) => ({ + id, + name, + description, + active, + activeVersionId, createdAt: createdAt.toISOString(), updatedAt: updatedAt.toISOString(), triggerCount, diff --git a/packages/cli/src/modules/workflow-index/__tests__/workflow-index.service.test.ts b/packages/cli/src/modules/workflow-index/__tests__/workflow-index.service.test.ts index e0c2cd4a9c5..4d53d469ada 100644 --- a/packages/cli/src/modules/workflow-index/__tests__/workflow-index.service.test.ts +++ b/packages/cli/src/modules/workflow-index/__tests__/workflow-index.service.test.ts @@ -44,6 +44,7 @@ describe('WorkflowIndexService', () => { id: 'workflow-123', name: 'Test Workflow', active: true, + activeVersionId: 'some-version-id', isArchived: false, createdAt: new Date(), updatedAt: new Date(), diff --git a/packages/cli/src/public-api/types.ts b/packages/cli/src/public-api/types.ts index f511d660cdd..d665e429292 100644 --- a/packages/cli/src/public-api/types.ts +++ b/packages/cli/src/public-api/types.ts @@ -82,7 +82,7 @@ export declare namespace WorkflowRequest { type Get = AuthenticatedRequest<{ id: string }, {}, {}, { excludePinnedData?: boolean }>; type Delete = Get; type Update = AuthenticatedRequest<{ id: string }, {}, WorkflowEntity, {}>; - type Activate = Get; + type Activate = AuthenticatedRequest<{ id: string }, {}, {}, {}>; type GetTags = Get; type UpdateTags = AuthenticatedRequest<{ id: string }, {}, TagEntity[]>; type Transfer = AuthenticatedRequest<{ id: string }, {}, { destinationProjectId: string }>; diff --git a/packages/cli/src/public-api/v1/handlers/credentials/spec/schemas/create-credential-response.yml b/packages/cli/src/public-api/v1/handlers/credentials/spec/schemas/create-credential-response.yml index fa906cfd293..04cfedc60b7 100644 --- a/packages/cli/src/public-api/v1/handlers/credentials/spec/schemas/create-credential-response.yml +++ b/packages/cli/src/public-api/v1/handlers/credentials/spec/schemas/create-credential-response.yml @@ -15,7 +15,7 @@ properties: example: John's Github account type: type: string - example: github + example: githubApi createdAt: type: string format: date-time diff --git a/packages/cli/src/public-api/v1/handlers/credentials/spec/schemas/credential.yml b/packages/cli/src/public-api/v1/handlers/credentials/spec/schemas/credential.yml index 08c208f59a2..b131d112f89 100644 --- a/packages/cli/src/public-api/v1/handlers/credentials/spec/schemas/credential.yml +++ b/packages/cli/src/public-api/v1/handlers/credentials/spec/schemas/credential.yml @@ -13,11 +13,11 @@ properties: example: Joe's Github Credentials type: type: string - example: github + example: githubApi data: type: object writeOnly: true - example: { token: 'ada612vad6fa5df4adf5a5dsf4389adsf76da7s' } + example: { accessToken: 'ada612vad6fa5df4adf5a5dsf4389adsf76da7s' } createdAt: type: string format: date-time diff --git a/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/activeVersion.yml b/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/activeVersion.yml new file mode 100644 index 00000000000..2afb5e1da71 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/activeVersion.yml @@ -0,0 +1,37 @@ +type: object +readOnly: true +nullable: true +additionalProperties: false +properties: + versionId: + type: string + readOnly: true + description: Unique identifier for this workflow version + example: 7c6b9e3f-8d4a-4b2c-9f1e-6a5d3b8c7e4f + workflowId: + type: string + readOnly: true + description: The workflow this version belongs to + example: 2tUt1wbLX592XDdX + nodes: + type: array + readOnly: true + items: + $ref: './node.yml' + connections: + type: object + readOnly: true + example: { Jira: { main: [[{ node: 'Jira', type: 'main', index: 0 }]] } } + authors: + type: string + readOnly: true + description: Comma-separated list of author IDs who contributed to this version + example: 1,2,3 + createdAt: + type: string + format: date-time + readOnly: true + updatedAt: + type: string + format: date-time + readOnly: true diff --git a/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/workflow.yml b/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/workflow.yml index 822bdc5c7ad..7f36ea82d07 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/workflow.yml +++ b/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/workflow.yml @@ -50,3 +50,5 @@ properties: type: array items: $ref: './sharedWorkflow.yml' + activeVersion: + $ref: './activeVersion.yml' diff --git a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts index ef126b29d36..719aa8a259a 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts @@ -2,7 +2,7 @@ import { GlobalConfig } from '@n8n/config'; import { WorkflowEntity, ProjectRepository, TagRepository, WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import -import { In, Like, QueryFailedError } from '@n8n/typeorm'; +import { In, IsNull, Like, Not, QueryFailedError } from '@n8n/typeorm'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import type { FindOptionsWhere } from '@n8n/typeorm'; import type express from 'express'; @@ -12,7 +12,11 @@ import { z } from 'zod'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; -import { addNodeIds, replaceInvalidCredentials } from '@/workflow-helpers'; +import { + addNodeIds, + getActiveVersionUpdateValue, + replaceInvalidCredentials, +} from '@/workflow-helpers'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; import { WorkflowService } from '@/workflows/workflow.service'; @@ -116,7 +120,10 @@ export = { id, req.user, ['workflow:read'], - { includeTags: !Container.get(GlobalConfig).tags.disabled }, + { + includeTags: !Container.get(GlobalConfig).tags.disabled, + includeActiveVersion: true, + }, ); if (!workflow) { @@ -153,10 +160,18 @@ export = { } = req.query; const where: FindOptionsWhere = { - ...(active !== undefined && { active }), ...(name !== undefined && { name: Like('%' + name.trim() + '%') }), }; + // Filter by active status based on activeVersionId + if (active !== undefined) { + if (active) { + where.activeVersionId = Not(IsNull()); + } else { + where.activeVersionId = IsNull(); + } + } + if (['global:owner', 'global:admin'].includes(req.user.role.slug)) { if (tags) { const workflowIds = await Container.get(TagRepository).getWorkflowIdsViaTags( @@ -211,10 +226,11 @@ export = { where.id = In(workflowsIds); } - const selectFields: (keyof WorkflowEntity)[] = [ + const selectFields: Array = [ 'id', 'name', 'active', + 'activeVersionId', 'createdAt', 'updatedAt', 'isArchived', @@ -232,7 +248,7 @@ export = { selectFields.push('pinData'); } - const relations = ['shared']; + const relations = ['shared', 'activeVersion']; if (!Container.get(GlobalConfig).tags.disabled) { relations.push('tags'); } @@ -279,6 +295,7 @@ export = { id, req.user, ['workflow:update'], + { includeActiveVersion: true }, ); if (!workflow) { @@ -292,13 +309,28 @@ export = { const workflowManager = Container.get(ActiveWorkflowManager); - if (workflow.active) { + if (workflow.activeVersionId !== null) { // When workflow gets saved always remove it as the triggers could have been // changed and so the changes would not take effect await workflowManager.remove(id); } try { + // First add a record to workflow history to be able to get the full version object during the update + await Container.get(WorkflowHistoryService).saveVersion(req.user, updateData, workflow.id); + + const updatedVersion = await Container.get(WorkflowHistoryService).getVersion( + req.user, + id, + updateData.versionId, + ); + + updateData.activeVersion = getActiveVersionUpdateValue( + workflow, + updatedVersion, + undefined, // active is read-only + ); + await updateWorkflow(workflow, updateData); } catch (error) { if (error instanceof Error) { @@ -306,7 +338,7 @@ export = { } } - if (workflow.active) { + if (workflow.activeVersionId !== null) { try { await workflowManager.add(workflow.id, 'update'); } catch (error) { @@ -318,14 +350,6 @@ export = { const updatedWorkflow = await getWorkflowById(workflow.id); - if (updatedWorkflow) { - await Container.get(WorkflowHistoryService).saveVersion( - req.user, - updatedWorkflow, - workflow.id, - ); - } - await Container.get(ExternalHooks).run('workflow.afterUpdate', [updateData]); Container.get(EventService).emit('workflow-saved', { user: req.user, @@ -346,6 +370,7 @@ export = { id, req.user, ['workflow:update'], + { includeActiveVersion: true }, ); if (!workflow) { @@ -354,20 +379,35 @@ export = { return res.status(404).json({ message: 'Not Found' }); } - if (!workflow.active) { + const activeVersionId = workflow.versionId; + + const newVersionIsBeingActivated = + activeVersionId && activeVersionId !== workflow.activeVersion?.versionId; + + if (!workflow.activeVersionId || newVersionIsBeingActivated) { try { + // change the status to active in the DB + const activeVersion = await setWorkflowAsActive(req.user, workflow.id, activeVersionId); + await Container.get(ActiveWorkflowManager).add(workflow.id, 'activate'); + + // Update the workflow object for response + workflow.active = true; + workflow.activeVersionId = activeVersionId; + workflow.activeVersion = activeVersion; } catch (error) { + // Rollback: restore previous state + await Container.get(WorkflowRepository).update(workflow.id, { + active: workflow.active, + activeVersion: workflow.activeVersion, + updatedAt: new Date(), + }); + if (error instanceof Error) { return res.status(400).json({ message: error.message }); } } - // change the status to active in the DB - await setWorkflowAsActive(workflow.id); - - workflow.active = true; - Container.get(EventService).emit('workflow-activated', { user: req.user, workflowId: workflow.id, @@ -378,7 +418,7 @@ export = { return res.json(workflow); } - // nothing to do as the workflow is already active + // nothing to do as this version is already active return res.json(workflow); }, ], @@ -402,12 +442,15 @@ export = { const activeWorkflowManager = Container.get(ActiveWorkflowManager); - if (workflow.active) { + if (workflow.activeVersionId) { await activeWorkflowManager.remove(workflow.id); await setWorkflowAsInactive(workflow.id); + // Update the workflow object for response workflow.active = false; + workflow.activeVersionId = null; + workflow.activeVersion = null; Container.get(EventService).emit('workflow-deactivated', { user: req.user, diff --git a/packages/cli/src/public-api/v1/handlers/workflows/workflows.service.ts b/packages/cli/src/public-api/v1/handlers/workflows/workflows.service.ts index 83dd1c00af7..9fe88b98032 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/workflows.service.ts +++ b/packages/cli/src/public-api/v1/handlers/workflows/workflows.service.ts @@ -13,6 +13,7 @@ import { PROJECT_OWNER_ROLE_SLUG, type Scope, type WorkflowSharingRole } from '@ import type { WorkflowId } from 'n8n-workflow'; import { License } from '@/license'; +import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; import { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; function insertIf(condition: boolean, elements: string[]): string[] { @@ -85,16 +86,26 @@ export async function createWorkflow( }); } -export async function setWorkflowAsActive(workflowId: WorkflowId) { +export async function setWorkflowAsActive(user: User, workflowId: WorkflowId, versionId: string) { + const activeVersion = await Container.get(WorkflowHistoryService).getVersion( + user, + workflowId, + versionId, + ); + await Container.get(WorkflowRepository).update(workflowId, { active: true, + activeVersion, updatedAt: new Date(), }); + + return activeVersion; } export async function setWorkflowAsInactive(workflowId: WorkflowId) { return await Container.get(WorkflowRepository).update(workflowId, { active: false, + activeVersion: null, updatedAt: new Date(), }); } diff --git a/packages/cli/src/scaling/job-processor.ts b/packages/cli/src/scaling/job-processor.ts index e54dc498d95..0699f0aca2f 100644 --- a/packages/cli/src/scaling/job-processor.ts +++ b/packages/cli/src/scaling/job-processor.ts @@ -26,6 +26,7 @@ import type { import { EventService } from '@/events/event.service'; import { getLifecycleHooksForScalingWorker } from '@/execution-lifecycle/execution-lifecycle-hooks'; +import { getWorkflowActiveStatusFromWorkflowData } from '@/executions/execution.utils'; import { ManualExecutionService } from '@/manual-execution.service'; import { NodeTypes } from '@/node-types'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; @@ -114,7 +115,7 @@ export class JobProcessor { name: execution.workflowData.name, nodes: execution.workflowData.nodes, connections: execution.workflowData.connections, - active: execution.workflowData.active, + active: getWorkflowActiveStatusFromWorkflowData(execution.workflowData), nodeTypes: this.nodeTypes, staticData, settings: execution.workflowData.settings, diff --git a/packages/cli/src/security-audit/risk-reporters/credentials-risk-reporter.ts b/packages/cli/src/security-audit/risk-reporters/credentials-risk-reporter.ts index 7724d07467a..27818a24661 100644 --- a/packages/cli/src/security-audit/risk-reporters/credentials-risk-reporter.ts +++ b/packages/cli/src/security-audit/risk-reporters/credentials-risk-reporter.ts @@ -19,7 +19,7 @@ export class CredentialsRiskReporter implements RiskReporter { const days = this.securityConfig.daysAbandonedWorkflow; const allExistingCreds = await this.getAllExistingCreds(); - const { credsInAnyUse, credsInActiveUse } = await this.getAllCredsInUse(workflows); + const { credsInAnyUse, credsInActiveUse } = this.getAllCredsInUse(workflows); const recentlyExecutedCreds = await this.getCredsInRecentlyExecutedWorkflows(days); const credsNotInAnyUse = allExistingCreds.filter((c) => !credsInAnyUse.has(c.id)); @@ -81,7 +81,7 @@ export class CredentialsRiskReporter implements RiskReporter { return report; } - private async getAllCredsInUse(workflows: IWorkflowBase[]) { + private getAllCredsInUse(workflows: IWorkflowBase[]) { const credsInAnyUse = new Set(); const credsInActiveUse = new Set(); @@ -94,7 +94,9 @@ export class CredentialsRiskReporter implements RiskReporter { credsInAnyUse.add(cred.id); - if (workflow.active) credsInActiveUse.add(cred.id); + if (workflow.activeVersionId !== null) { + credsInActiveUse.add(cred.id); + } }); }); }); diff --git a/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts b/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts index b396ec07278..89f94bab124 100644 --- a/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts +++ b/packages/cli/src/security-audit/risk-reporters/instance-risk-reporter.ts @@ -130,7 +130,7 @@ export class InstanceRiskReporter implements RiskReporter { private getUnprotectedWebhookNodes(workflows: IWorkflowBase[]) { return workflows.reduce((acc, workflow) => { - if (!workflow.active) return acc; + if (!workflow.activeVersionId) return acc; workflow.nodes.forEach((node) => { if ( diff --git a/packages/cli/src/security-audit/security-audit.service.ts b/packages/cli/src/security-audit/security-audit.service.ts index ea91d55c674..4b712333de6 100644 --- a/packages/cli/src/security-audit/security-audit.service.ts +++ b/packages/cli/src/security-audit/security-audit.service.ts @@ -30,7 +30,7 @@ export class SecurityAuditService { } const workflows = await this.workflowRepository.find({ - select: ['id', 'name', 'active', 'nodes', 'connections'], + select: ['id', 'name', 'active', 'activeVersionId', 'nodes', 'connections'], }); const promises = categories.map(async (c) => await this.reporters[c].report(workflows)); diff --git a/packages/cli/src/services/__tests__/hooks.service.test.ts b/packages/cli/src/services/__tests__/hooks.service.test.ts index f781ab32e4f..aa9fc2c39bb 100644 --- a/packages/cli/src/services/__tests__/hooks.service.test.ts +++ b/packages/cli/src/services/__tests__/hooks.service.test.ts @@ -6,6 +6,7 @@ import type { WorkflowRepository, UserRepository, } from '@n8n/db'; +import { IsNull, Not } from '@n8n/typeorm'; import RudderStack from '@rudderstack/rudder-sdk-node'; import type { Response } from 'express'; import { mock } from 'jest-mock-extended'; @@ -100,7 +101,7 @@ describe('HooksService', () => { it('hooksService.workflowsCount should call workflowRepository.count', async () => { // ARRANGE - const filter = { where: { active: true } }; + const filter = { where: { activeVersionId: Not(IsNull()) } }; // ACT await hooksService.workflowsCount(filter); diff --git a/packages/cli/src/services/import.service.ts b/packages/cli/src/services/import.service.ts index b24d3108199..cad342ce182 100644 --- a/packages/cli/src/services/import.service.ts +++ b/packages/cli/src/services/import.service.ts @@ -87,8 +87,9 @@ export class ImportService { const { manager: dbManager } = this.credentialsRepository; await dbManager.transaction(async (tx) => { for (const workflow of workflows) { - if (workflow.active) { + if (workflow.active || workflow.activeVersionId) { workflow.active = false; + workflow.activeVersionId = null; this.logger.info(`Deactivating workflow "${workflow.name}". Remember to activate later.`); } diff --git a/packages/cli/src/webhooks/__tests__/live-webhooks.test.ts b/packages/cli/src/webhooks/__tests__/live-webhooks.test.ts new file mode 100644 index 00000000000..00adbc5cd36 --- /dev/null +++ b/packages/cli/src/webhooks/__tests__/live-webhooks.test.ts @@ -0,0 +1,135 @@ +import { mockLogger } from '@n8n/backend-test-utils'; +import type { WebhookEntity, WorkflowEntity, WorkflowHistory, WorkflowRepository } from '@n8n/db'; +import type { Response } from 'express'; +import { mock } from 'jest-mock-extended'; +import type { + IHttpRequestMethods, + INode, + INodeType, + IWebhookData, + IWorkflowExecuteAdditionalData, + Workflow, +} from 'n8n-workflow'; + +import type { NodeTypes } from '@/node-types'; +import { LiveWebhooks } from '@/webhooks/live-webhooks'; +import * as WebhookHelpers from '@/webhooks/webhook-helpers'; +import type { WebhookService } from '@/webhooks/webhook.service'; +import type { WebhookRequest } from '@/webhooks/webhook.types'; +import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; +import type { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service'; + +jest.mock('@/webhooks/webhook-helpers'); +jest.mock('@/workflow-execute-additional-data'); + +describe('LiveWebhooks', () => { + const workflowRepository = mock(); + const webhookService = mock(); + const nodeTypes = mock(); + const workflowStaticDataService = mock(); + + let liveWebhooks: LiveWebhooks; + + beforeEach(() => { + jest.clearAllMocks(); + liveWebhooks = new LiveWebhooks( + mockLogger(), + nodeTypes, + webhookService, + workflowRepository, + workflowStaticDataService, + ); + + // Mock WorkflowExecuteAdditionalData.getBase to avoid DI issues + (WorkflowExecuteAdditionalData.getBase as jest.Mock).mockResolvedValue( + mock(), + ); + }); + + describe('executeWebhook', () => { + it('should use active version nodes when executing webhook', async () => { + const workflowId = 'workflow-1'; + const nodeName = 'Webhook'; + const webhookPath = 'test-webhook'; + const httpMethod: IHttpRequestMethods = 'GET'; + + const createWebhookNode = (id: string, position: [number, number]): INode => ({ + id, + name: nodeName, + type: 'n8n-nodes-base.webhook', + typeVersion: 1, + position, + parameters: { path: webhookPath, httpMethod }, + }); + + const draftNodes = [createWebhookNode('webhook-node-draft', [0, 0])]; + const activeNodes = [createWebhookNode('webhook-node-active', [100, 200])]; + + const activeVersion = mock({ + versionId: 'v1', + workflowId, + nodes: activeNodes, + connections: {}, + authors: 'test-user', + createdAt: new Date(), + updatedAt: new Date(), + }); + + const workflowEntity = mock({ + id: workflowId, + name: 'Test Workflow', + active: true, + activeVersionId: activeVersion.versionId, + nodes: draftNodes, + connections: {}, + activeVersion, + shared: [{ role: 'workflow:owner', project: { id: 'project-1', projectRelations: [] } }], + }); + + const webhookEntity = mock({ + workflowId, + node: nodeName, + webhookPath, + method: httpMethod, + isDynamic: false, + }); + + const webhookNodeType = mock({ + description: { name: nodeName, properties: [] }, + webhook: jest.fn(), + }); + + const webhookData = mock({ + httpMethod, + path: webhookPath, + node: nodeName, + webhookDescription: {}, + workflowId, + }); + + webhookService.findWebhook.mockResolvedValue(webhookEntity); + webhookService.getWebhookMethods.mockResolvedValue([httpMethod]); + workflowRepository.findOne.mockResolvedValue(workflowEntity); + nodeTypes.getByNameAndVersion.mockReturnValue(webhookNodeType); + webhookService.getNodeWebhooks.mockReturnValue([webhookData]); + + let capturedNodes: INode[] = []; + (WebhookHelpers.executeWebhook as jest.Mock).mockImplementation( + (workflow: Workflow, ...args: unknown[]) => { + capturedNodes = Object.values(workflow.nodes); + const webhookCallback = args[args.length - 1] as ( + error: Error | null, + data: object, + ) => void; + void webhookCallback(null, {}); + }, + ); + + const request = mock({ method: httpMethod, params: { path: webhookPath } }); + + await liveWebhooks.executeWebhook(request, mock()); + + expect(capturedNodes[0].id).toBe('webhook-node-active'); + }); + }); +}); diff --git a/packages/cli/src/webhooks/__tests__/waiting-forms.test.ts b/packages/cli/src/webhooks/__tests__/waiting-forms.test.ts index 522a6cb292e..a85a5b7fae6 100644 --- a/packages/cli/src/webhooks/__tests__/waiting-forms.test.ts +++ b/packages/cli/src/webhooks/__tests__/waiting-forms.test.ts @@ -3,13 +3,19 @@ import type express from 'express'; import { mock } from 'jest-mock-extended'; import { FORM_NODE_TYPE, WAITING_FORMS_EXECUTION_STATUS, type Workflow } from 'n8n-workflow'; +import type { WaitingWebhookRequest } from '../webhook.types'; + import { WaitingForms } from '@/webhooks/waiting-forms'; -import type { WaitingWebhookRequest } from '../webhook.types'; +class TestWaitingForms extends WaitingForms { + exposeGetWorkflow(execution: IExecutionResponse): Workflow { + return this.getWorkflow(execution); + } +} describe('WaitingForms', () => { const executionRepository = mock(); - const waitingForms = new WaitingForms(mock(), mock(), executionRepository, mock(), mock()); + const waitingForms = new TestWaitingForms(mock(), mock(), executionRepository, mock(), mock()); beforeEach(() => { jest.restoreAllMocks(); @@ -220,5 +226,93 @@ describe('WaitingForms', () => { expect(result).toEqual({ noWebhookResponse: true }); expect(res.send).toHaveBeenCalledWith(execution.status); }); + + it('should handle old executions with missing activeVersionId field when active=true', () => { + const execution = mock({ + workflowData: { + id: 'workflow1', + name: 'Test Workflow', + nodes: [], + connections: {}, + active: true, + activeVersionId: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted + settings: {}, + staticData: {}, + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const workflow = waitingForms.exposeGetWorkflow(execution); + + expect(workflow.active).toBe(true); + }); + + it('should handle old executions with missing activeVersionId field when active=false', () => { + const execution = mock({ + workflowData: { + id: 'workflow1', + name: 'Test Workflow', + nodes: [], + connections: {}, + active: false, + activeVersionId: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted + settings: {}, + staticData: {}, + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const workflow = waitingForms.exposeGetWorkflow(execution); + + expect(workflow.active).toBe(false); + }); + + it('should set active to true when activeVersionId exists', () => { + const execution = mock({ + workflowData: { + id: 'workflow1', + name: 'Test Workflow', + nodes: [], + connections: {}, + active: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted + activeVersionId: 'version-123', + settings: {}, + staticData: {}, + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const workflow = waitingForms.exposeGetWorkflow(execution); + + expect(workflow.active).toBe(true); + }); + + it('should set active to false when activeVersionId is null', () => { + const execution = mock({ + workflowData: { + id: 'workflow1', + name: 'Test Workflow', + nodes: [], + connections: {}, + active: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted + activeVersionId: null, + settings: {}, + staticData: {}, + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const workflow = waitingForms.exposeGetWorkflow(execution); + + expect(workflow.active).toBe(false); + }); }); }); diff --git a/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts b/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts index e8f3c711515..24d18d8cb9a 100644 --- a/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts +++ b/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts @@ -3,19 +3,26 @@ import type express from 'express'; import { mock } from 'jest-mock-extended'; import type { InstanceSettings } from 'n8n-core'; import { generateUrlSignature, prepareUrlForSigning, WAITING_TOKEN_QUERY_PARAM } from 'n8n-core'; +import type { IWorkflowBase, Workflow } from 'n8n-workflow'; import { ConflictError } from '@/errors/response-errors/conflict.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { WaitingWebhooks } from '@/webhooks/waiting-webhooks'; import type { WaitingWebhookRequest } from '@/webhooks/webhook.types'; +class TestWaitingWebhooks extends WaitingWebhooks { + exposeCreateWorkflow(workflowData: IWorkflowBase): Workflow { + return this.createWorkflow(workflowData); + } +} + describe('WaitingWebhooks', () => { const SIGNING_SECRET = 'test-secret'; const executionRepository = mock(); const mockInstanceSettings = mock({ hmacSignatureSecret: SIGNING_SECRET, }); - const waitingWebhooks = new WaitingWebhooks( + const waitingWebhooks = new TestWaitingWebhooks( mock(), mock(), executionRepository, @@ -197,4 +204,88 @@ describe('WaitingWebhooks', () => { expect(result).toBe(false); }); }); + + describe('createWorkflow', () => { + it('should handle old executions with missing activeVersionId field when active=true', () => { + const workflowData = { + id: 'workflow1', + name: 'Test Workflow', + nodes: [], + connections: {}, + active: true, + activeVersionId: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted + settings: {}, + staticData: {}, + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + // @ts-expect-error: createWorkflow typing is incorrect, will be fixed later + const workflow = waitingWebhooks.exposeCreateWorkflow(workflowData); + + expect(workflow.active).toBe(true); + }); + + it('should handle old executions with missing activeVersionId field when active=false', () => { + const workflowData = { + id: 'workflow1', + name: 'Test Workflow', + nodes: [], + connections: {}, + active: false, + activeVersionId: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted + settings: {}, + staticData: {}, + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + // @ts-expect-error: createWorkflow typing is incorrect, will be fixed later + const workflow = waitingWebhooks.exposeCreateWorkflow(workflowData); + + expect(workflow.active).toBe(false); + }); + + it('should set active to true when activeVersionId exists', () => { + const workflowData: IWorkflowBase = { + id: 'workflow1', + name: 'Test Workflow', + nodes: [], + connections: {}, + active: true, + activeVersionId: 'version-123', + settings: {}, + staticData: {}, + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const workflow = waitingWebhooks.exposeCreateWorkflow(workflowData); + + expect(workflow.active).toBe(true); + }); + + it('should set active to false when activeVersionId is null', () => { + const workflowData: IWorkflowBase = { + id: 'workflow1', + name: 'Test Workflow', + nodes: [], + connections: {}, + active: false, + activeVersionId: null, + settings: {}, + staticData: {}, + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const workflow = waitingWebhooks.exposeCreateWorkflow(workflowData); + + expect(workflow.active).toBe(false); + }); + }); }); diff --git a/packages/cli/src/webhooks/live-webhooks.ts b/packages/cli/src/webhooks/live-webhooks.ts index 96b09d4dcf9..a94fa853d2a 100644 --- a/packages/cli/src/webhooks/live-webhooks.ts +++ b/packages/cli/src/webhooks/live-webhooks.ts @@ -96,19 +96,30 @@ export class LiveWebhooks implements IWebhookManager { const workflowData = await this.workflowRepository.findOne({ where: { id: webhook.workflowId }, - relations: { shared: { project: { projectRelations: true } } }, + relations: { + activeVersion: true, + shared: { project: { projectRelations: true } }, + }, }); if (workflowData === null) { throw new NotFoundError(`Could not find workflow with id "${webhook.workflowId}"`); } + if (!workflowData.activeVersion) { + throw new NotFoundError( + `Active version not found for workflow with id "${webhook.workflowId}"`, + ); + } + + const { nodes, connections } = workflowData.activeVersion; + const workflow = new Workflow({ id: webhook.workflowId, name: workflowData.name, - nodes: workflowData.nodes, - connections: workflowData.connections, - active: workflowData.active, + nodes, + connections, + active: workflowData.activeVersionId !== null, nodeTypes: this.nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings, diff --git a/packages/cli/src/webhooks/waiting-forms.ts b/packages/cli/src/webhooks/waiting-forms.ts index 8a97fa27bf3..0d51ce69421 100644 --- a/packages/cli/src/webhooks/waiting-forms.ts +++ b/packages/cli/src/webhooks/waiting-forms.ts @@ -10,6 +10,7 @@ import { } from 'n8n-workflow'; import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { getWorkflowActiveStatusFromWorkflowData } from '@/executions/execution.utils'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { WaitingWebhooks } from '@/webhooks/waiting-webhooks'; @@ -30,14 +31,14 @@ export class WaitingForms extends WaitingWebhooks { } } - private getWorkflow(execution: IExecutionResponse) { + protected getWorkflow(execution: IExecutionResponse) { const { workflowData } = execution; return new Workflow({ id: workflowData.id, name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, - active: workflowData.active, + active: getWorkflowActiveStatusFromWorkflowData(workflowData), nodeTypes: this.nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings, diff --git a/packages/cli/src/webhooks/waiting-webhooks.ts b/packages/cli/src/webhooks/waiting-webhooks.ts index abfe55b4ff5..f05a3f88165 100644 --- a/packages/cli/src/webhooks/waiting-webhooks.ts +++ b/packages/cli/src/webhooks/waiting-webhooks.ts @@ -29,6 +29,7 @@ import type { import { ConflictError } from '@/errors/response-errors/conflict.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { getWorkflowActiveStatusFromWorkflowData } from '@/executions/execution.utils'; import { NodeTypes } from '@/node-types'; import * as WebhookHelpers from '@/webhooks/webhook-helpers'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; @@ -70,13 +71,14 @@ export class WaitingWebhooks implements IWebhookManager { ); } - private createWorkflow(workflowData: IWorkflowBase) { + // TODO: fix the type here - it should be execution workflowData + protected createWorkflow(workflowData: IWorkflowBase) { return new Workflow({ id: workflowData.id, name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, - active: workflowData.active, + active: getWorkflowActiveStatusFromWorkflowData(workflowData), nodeTypes: this.nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings, diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index fe9238988dc..414934b2e29 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -92,6 +92,10 @@ export function getRunData( }; } +/** + * Loads workflow data for sub-workflow execution. + * Uses the active version when available. + */ export async function getWorkflowData( workflowInfo: IExecuteWorkflowInfo, parentWorkflowId: string, @@ -105,27 +109,35 @@ export async function getWorkflowData( let workflowData: IWorkflowBase | null; if (workflowInfo.id !== undefined) { - const relations = Container.get(GlobalConfig).tags.disabled ? [] : ['tags']; + const baseRelations = ['activeVersion']; + const relations = Container.get(GlobalConfig).tags.disabled + ? [...baseRelations] + : [...baseRelations, 'tags']; - workflowData = await Container.get(WorkflowRepository).get( + const workflowFromDb = await Container.get(WorkflowRepository).get( { id: workflowInfo.id }, { relations }, ); - if (workflowData === undefined || workflowData === null) { + if (workflowFromDb === undefined || workflowFromDb === null) { throw new UnexpectedError('Workflow does not exist.', { extra: { workflowId: workflowInfo.id }, }); } + + if (workflowFromDb.activeVersion) { + workflowFromDb.nodes = workflowFromDb.activeVersion.nodes; + workflowFromDb.connections = workflowFromDb.activeVersion.connections; + } + + workflowData = workflowFromDb; } else { workflowData = workflowInfo.code ?? null; if (workflowData) { if (!workflowData.id) { workflowData.id = parentWorkflowId; } - if (!workflowData.settings) { - workflowData.settings = parentWorkflowSettings; - } + workflowData.settings ??= parentWorkflowSettings; } } @@ -183,7 +195,7 @@ async function startExecution( name: workflowName, nodes: workflowData.nodes, connections: workflowData.connections, - active: workflowData.active, + active: workflowData.activeVersionId !== null, nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings, diff --git a/packages/cli/src/workflow-helpers.ts b/packages/cli/src/workflow-helpers.ts index e1d52e03740..814a0d3f1d8 100644 --- a/packages/cli/src/workflow-helpers.ts +++ b/packages/cli/src/workflow-helpers.ts @@ -1,4 +1,5 @@ import { CredentialsRepository } from '@n8n/db'; +import type { WorkflowEntity, WorkflowHistory } from '@n8n/db'; import { Container } from '@n8n/di'; import type { IDataObject, @@ -215,3 +216,28 @@ export function shouldRestartParentExecution( } return parentExecution.shouldResume; } + +/** + * Determines the value to set for a workflow's active version based on the provided parameters. + * Always updates the active version to the current version for active workflows, clears it when deactivating. + * + * @param dbWorkflow - The current workflow entity from the database, before the update + * @param updatedVersion - The workflow history version of the updated workflow + * @param updatedActive - Optional boolean indicating if the workflow's active status is being updated + * @returns The workflow history version to set as active, null if deactivating, or the existing active version if unchanged + */ +export function getActiveVersionUpdateValue( + dbWorkflow: WorkflowEntity, + updatedVersion: WorkflowHistory, + updatedActive?: boolean, +) { + if (updatedActive) { + return updatedVersion; + } + + if (updatedActive === false) { + return null; + } + + return dbWorkflow.activeVersionId ? updatedVersion : null; +} diff --git a/packages/cli/src/workflow-runner.ts b/packages/cli/src/workflow-runner.ts index d35e1dfd024..2d1db28472c 100644 --- a/packages/cli/src/workflow-runner.ts +++ b/packages/cli/src/workflow-runner.ts @@ -235,7 +235,7 @@ export class WorkflowRunner { name: data.workflowData.name, nodes: data.workflowData.nodes, connections: data.workflowData.connections, - active: data.workflowData.active, + active: data.workflowData.activeVersionId !== null, nodeTypes: this.nodeTypes, staticData: data.workflowData.staticData, settings: workflowSettings, diff --git a/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts b/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts index a7d58372bae..c5a49199afc 100644 --- a/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts @@ -93,7 +93,11 @@ describe('WorkflowExecutionService', () => { describe('runWorkflow()', () => { test('should call `WorkflowRunner.run()`', async () => { const node = mock(); - const workflow = mock({ active: true, nodes: [node] }); + const workflow = mock({ + active: true, + activeVersionId: 'some-version-id', + nodes: [node], + }); workflowRunner.run.mockResolvedValue('fake-execution-id'); @@ -104,6 +108,10 @@ describe('WorkflowExecutionService', () => { }); describe('executeManually()', () => { + beforeEach(() => { + workflowRunner.run.mockClear(); + }); + test('should call `WorkflowRunner.run()` with correct parameters with default partial execution logic', async () => { const executionId = 'fake-execution-id'; const userId = 'user-id'; @@ -253,6 +261,7 @@ describe('WorkflowExecutionService', () => { id: 'abc', name: 'test', active: false, + activeVersionId: null, isArchived: false, pinData: { [pinnedTrigger.name]: [{ json: {} }], @@ -320,6 +329,7 @@ describe('WorkflowExecutionService', () => { id: 'abc', name: 'test', active: false, + activeVersionId: null, isArchived: false, pinData: { [pinnedTrigger.name]: [{ json: {} }], @@ -355,6 +365,38 @@ describe('WorkflowExecutionService', () => { }); expect(result).toEqual({ executionId }); }); + + test('should force current version for manual execution even if workflow has active version', async () => { + const executionId = 'fake-execution-id'; + const userId = 'user-id'; + const user = mock({ id: userId }); + const runPayload: WorkflowRequest.ManualRunPayload = { + workflowData: { + id: 'workflow-id', + name: 'Test Workflow', + active: true, + activeVersionId: 'version-123', + isArchived: false, + nodes: [], + connections: {}, + createdAt: new Date(), + updatedAt: new Date(), + }, + startNodes: [], + destinationNode: undefined, + }; + + workflowRunner.run.mockResolvedValue(executionId); + + const result = await workflowExecutionService.executeManually(runPayload, user); + + expect(workflowRunner.run).toHaveBeenCalledTimes(1); + const callArgs = workflowRunner.run.mock.calls[0][0]; + expect(callArgs.workflowData.active).toBe(false); + expect(callArgs.workflowData.activeVersionId).toBe(null); + expect(callArgs.executionMode).toBe('manual'); + expect(result).toEqual({ executionId }); + }); }); describe('selectPinnedActivatorStarter()', () => { @@ -625,6 +667,7 @@ describe('WorkflowExecutionService', () => { id: 'error-workflow-id', name: 'Error Workflow', active: false, + activeVersionId: null, isArchived: false, pinData: {}, nodes: [errorTriggerNode], diff --git a/packages/cli/src/workflows/workflow-execution.service.ts b/packages/cli/src/workflows/workflow-execution.service.ts index 5fe4f116a0e..1b1f624bb8c 100644 --- a/packages/cli/src/workflows/workflow-execution.service.ts +++ b/packages/cli/src/workflows/workflow-execution.service.ts @@ -165,6 +165,7 @@ export class WorkflowExecutionService { // For manual testing always set to not active workflowData.active = false; + workflowData.activeVersionId = null; // Start the workflow const data: IWorkflowExecutionDataProcess = { @@ -283,7 +284,7 @@ export class WorkflowExecutionService { nodeTypes: this.nodeTypes, nodes: workflowData.nodes, connections: workflowData.connections, - active: workflowData.active, + active: workflowData.activeVersion !== null, staticData: workflowData.staticData, settings: workflowData.settings, }); @@ -438,7 +439,7 @@ export class WorkflowExecutionService { new Workflow({ nodes: workflow.nodes, connections: workflow.connections, - active: workflow.active, + active: workflow.activeVersionId !== null, nodeTypes: this.nodeTypes, }).getParentNodes(destinationNode), ); @@ -466,7 +467,7 @@ export class WorkflowExecutionService { const parentNodeNames = new Workflow({ nodes: workflow.nodes, connections: workflow.connections, - active: workflow.active, + active: workflow.activeVersionId !== null, nodeTypes: this.nodeTypes, }).getParentNodes(firstStartNodeName); diff --git a/packages/cli/src/workflows/workflow-finder.service.ts b/packages/cli/src/workflows/workflow-finder.service.ts index 5679dbeec14..4647aa5bf7f 100644 --- a/packages/cli/src/workflows/workflow-finder.service.ts +++ b/packages/cli/src/workflows/workflow-finder.service.ts @@ -24,6 +24,7 @@ export class WorkflowFinderService { options: { includeTags?: boolean; includeParentFolder?: boolean; + includeActiveVersion?: boolean; em?: EntityManager; } = {}, ) { @@ -50,6 +51,7 @@ export class WorkflowFinderService { where, includeTags: options.includeTags, includeParentFolder: options.includeParentFolder, + includeActiveVersion: options.includeActiveVersion, em: options.em, }); diff --git a/packages/cli/src/workflows/workflow-history/workflow-history-manager.ts b/packages/cli/src/workflows/workflow-history/workflow-history-manager.ts index 85e2e9669f2..7051f8f0f9d 100644 --- a/packages/cli/src/workflows/workflow-history/workflow-history-manager.ts +++ b/packages/cli/src/workflows/workflow-history/workflow-history-manager.ts @@ -34,6 +34,6 @@ export class WorkflowHistoryManager { } const pruneDateTime = DateTime.now().minus({ hours: pruneHours }).toJSDate(); - await this.workflowHistoryRepo.deleteEarlierThanExceptCurrent(pruneDateTime); + await this.workflowHistoryRepo.deleteEarlierThanExceptCurrentAndActive(pruneDateTime); } } diff --git a/packages/cli/src/workflows/workflow-history/workflow-history.service.ts b/packages/cli/src/workflows/workflow-history/workflow-history.service.ts index 59b8692b355..fb2578b7b5f 100644 --- a/packages/cli/src/workflows/workflow-history/workflow-history.service.ts +++ b/packages/cli/src/workflows/workflow-history/workflow-history.service.ts @@ -1,7 +1,9 @@ import { Logger } from '@n8n/backend-common'; -import type { User, WorkflowHistory } from '@n8n/db'; -import { WorkflowHistoryRepository } from '@n8n/db'; +import type { User } from '@n8n/db'; +import { WorkflowHistory, WorkflowHistoryRepository } from '@n8n/db'; import { Service } from '@n8n/di'; +// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import +import type { EntityManager } from '@n8n/typeorm'; import type { IWorkflowBase } from 'n8n-workflow'; import { ensureError, UnexpectedError } from 'n8n-workflow'; @@ -64,15 +66,24 @@ export class WorkflowHistoryService { return hist; } - async saveVersion(user: User, workflow: IWorkflowBase, workflowId: string) { + async saveVersion( + user: User, + workflow: IWorkflowBase, + workflowId: string, + transactionManager?: EntityManager, + ) { if (!workflow.nodes || !workflow.connections) { throw new UnexpectedError( `Cannot save workflow history: nodes and connections are required for workflow ${workflowId}`, ); } + const repository = transactionManager + ? transactionManager.getRepository(WorkflowHistory) + : this.workflowHistoryRepository; + try { - await this.workflowHistoryRepository.insert({ + await repository.insert({ authors: user.firstName + ' ' + user.lastName, connections: workflow.connections, nodes: workflow.nodes, diff --git a/packages/cli/src/workflows/workflow.service.ee.ts b/packages/cli/src/workflows/workflow.service.ee.ts index 1c9114bad6c..3ca96c0bf9c 100644 --- a/packages/cli/src/workflows/workflow.service.ee.ts +++ b/packages/cli/src/workflows/workflow.service.ee.ts @@ -329,7 +329,7 @@ export class EnterpriseWorkflowService { } // 6. deactivate workflow if necessary - const wasActive = workflow.active; + const wasActive = workflow.activeVersionId !== null; if (wasActive) { await this.activeWorkflowManager.remove(workflowId); } @@ -395,14 +395,14 @@ export class EnterpriseWorkflowService { // 2. Get all workflows in the nested folders const workflows = await this.workflowRepository.find({ - select: ['id', 'active', 'shared'], + select: ['id', 'activeVersionId', 'shared'], relations: ['shared', 'shared.project'], where: { parentFolder: { id: In([...childrenFolderIds, sourceFolderId]) }, }, }); - const activeWorkflows = workflows.filter((w) => w.active).map((w) => w.id); + const activeWorkflows = workflows.filter((w) => w.activeVersionId !== null).map((w) => w.id); // 3. get destination project const destinationProject = await this.projectService.getProjectWithScope( diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index 901e444e881..59d58d42740 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -18,7 +18,7 @@ import { In } from '@n8n/typeorm'; import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity'; import omit from 'lodash/omit'; import pick from 'lodash/pick'; -import { BinaryDataService } from 'n8n-core'; +import { FileLocation, BinaryDataService } from 'n8n-core'; import { NodeApiError, PROJECT_ROOT } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; @@ -205,9 +205,12 @@ export class WorkflowService { parentFolderId?: string, forceSave?: boolean, ): Promise { - const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [ - 'workflow:update', - ]); + const workflow = await this.workflowFinderService.findWorkflowForUser( + workflowId, + user, + ['workflow:update'], + { includeActiveVersion: true }, + ); if (!workflow) { this.logger.warn('User attempted to update a workflow without permissions', { @@ -230,7 +233,10 @@ export class WorkflowService { ); } - if (Object.keys(omit(workflowUpdateData, ['id', 'versionId', 'active'])).length > 0) { + if ( + Object.keys(omit(workflowUpdateData, ['id', 'versionId', 'active', 'activeVersionId'])) + .length > 0 + ) { // Update the workflow's version when changing properties such as // `name`, `pinData`, `nodes`, `connections`, `settings` or `tags` // This is necessary for collaboration to work properly - even when only name or settings @@ -246,8 +252,22 @@ export class WorkflowService { ); } + // Convert 'active' boolean from frontend to 'activeVersionId' for backend + if ('active' in workflowUpdateData) { + if (workflowUpdateData.active) { + workflowUpdateData.activeVersionId = workflowUpdateData.versionId ?? workflow.versionId; + } else { + workflowUpdateData.activeVersionId = null; + } + } + const versionChanged = workflowUpdateData.versionId && workflowUpdateData.versionId !== workflow.versionId; + const wasActive = workflow.activeVersionId !== null; + const isNowActive = workflowUpdateData.active ?? wasActive; + const activationStatusChanged = isNowActive !== wasActive; + const needsActiveVersionUpdate = activationStatusChanged || (versionChanged && isNowActive); + if (versionChanged) { // To save a version, we need both nodes and connections workflowUpdateData.nodes = workflowUpdateData.nodes ?? workflow.nodes; @@ -268,7 +288,7 @@ export class WorkflowService { * If a trigger or poller in the workflow was updated, the new value * will take effect only on removing and re-adding. */ - if (workflow.active) { + if (wasActive) { await this.activeWorkflowManager.remove(workflowId); } @@ -308,9 +328,30 @@ export class WorkflowService { 'staticData', 'pinData', 'versionId', + 'activeVersionId', 'description', ]); + // Save the workflow to history first, so we can retrieve the complete version object for the update + if (versionChanged) { + await this.workflowHistoryService.saveVersion(user, workflowUpdateData, workflowId); + } + + if (needsActiveVersionUpdate) { + const versionIdToFetch = versionChanged ? workflowUpdateData.versionId : workflow.versionId; + const version = await this.workflowHistoryService.getVersion( + user, + workflowId, + versionIdToFetch, + ); + + updatePayload.activeVersion = WorkflowHelpers.getActiveVersionUpdateValue( + workflow, + version, + isNowActive, + ); + } + if (parentFolderId) { const project = await this.sharedWorkflowRepository.getWorkflowOwningProject(workflow.id); if (parentFolderId !== PROJECT_ROOT) { @@ -334,10 +375,6 @@ export class WorkflowService { await this.workflowTagMappingRepository.overwriteTaggings(workflowId, tagIds); } - if (versionChanged) { - await this.workflowHistoryService.saveVersion(user, workflowUpdateData, workflowId); - } - const relations = tagsDisabled ? [] : ['tags']; // We sadly get nothing back from "update". Neither if it updated a record @@ -366,11 +403,7 @@ export class WorkflowService { publicApi: false, }); - // Check if workflow activation status changed - const wasActive = workflow.active; - const isNowActive = updatedWorkflow.active; - - if (isNowActive && !wasActive) { + if (activationStatusChanged && isNowActive) { // Workflow is being activated this.eventService.emit('workflow-activated', { user, @@ -378,7 +411,7 @@ export class WorkflowService { workflow: updatedWorkflow, publicApi: false, }); - } else if (!isNowActive && wasActive) { + } else if (activationStatusChanged && !isNowActive) { // Workflow is being deactivated this.eventService.emit('workflow-deactivated', { user, @@ -388,21 +421,24 @@ export class WorkflowService { }); } - if (updatedWorkflow.active) { + if (isNowActive) { // When the workflow is supposed to be active add it again try { await this.externalHooks.run('workflow.activate', [updatedWorkflow]); - await this.activeWorkflowManager.add(workflowId, workflow.active ? 'update' : 'activate'); + await this.activeWorkflowManager.add(workflowId, wasActive ? 'update' : 'activate'); } catch (error) { // If workflow could not be activated set it again to inactive - // and revert the versionId change so UI remains consistent + // and revert the versionId and activeVersionId change so UI remains consistent await this.workflowRepository.update(workflowId, { active: false, + activeVersion: null, versionId: workflow.versionId, }); // Also set it in the returned data updatedWorkflow.active = false; + updatedWorkflow.activeVersionId = null; + updatedWorkflow.activeVersion = null; // Emit deactivation event since activation failed this.eventService.emit('workflow-deactivated', { @@ -456,7 +492,9 @@ export class WorkflowService { select: ['id'], where: { workflowId }, }) - .then((rows) => rows.map(({ id: executionId }) => ({ workflowId, executionId }))); + .then((rows) => + rows.map(({ id: executionId }) => FileLocation.ofExecution(workflowId, executionId)), + ); await this.workflowRepository.delete(workflowId); await this.binaryDataService.deleteMany(idsForDeletion); @@ -488,7 +526,7 @@ export class WorkflowService { throw new BadRequestError('Workflow is already archived.'); } - if (workflow.active) { + if (workflow.activeVersionId !== null) { await this.activeWorkflowManager.remove(workflowId); } @@ -496,10 +534,13 @@ export class WorkflowService { workflow.versionId = versionId; workflow.isArchived = true; workflow.active = false; + workflow.activeVersionId = null; + workflow.activeVersion = null; await this.workflowRepository.update(workflowId, { isArchived: true, active: false, + activeVersion: null, versionId, }); diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 1193cedf196..bd4095b5d42 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -181,11 +181,29 @@ export class WorkflowsController { await transactionManager.save(newSharedWorkflow); + await this.workflowHistoryService.saveVersion( + req.user, + workflow, + workflow.id, + transactionManager, + ); + + const shouldActivate = req.body.active === true; + if (shouldActivate) { + workflow.activeVersionId = workflow.versionId; + await transactionManager.save(workflow); + } + return await this.workflowFinderService.findWorkflowForUser( workflow.id, req.user, ['workflow:read'], - { em: transactionManager, includeTags: true, includeParentFolder: true }, + { + em: transactionManager, + includeTags: true, + includeParentFolder: true, + includeActiveVersion: true, + }, ); }); @@ -194,8 +212,6 @@ export class WorkflowsController { throw new InternalServerError('Failed to save workflow'); } - await this.workflowHistoryService.saveVersion(req.user, savedWorkflow, savedWorkflow.id); - if (tagIds && !this.globalConfig.tags.disabled && savedWorkflow.tags) { savedWorkflow.tags = this.tagService.sortByRequestOrder(savedWorkflow.tags, { requestOrder: tagIds, diff --git a/packages/cli/test/integration/access-control/cross-project-access.test.ts b/packages/cli/test/integration/access-control/cross-project-access.test.ts index dabf6227a4a..29b11466999 100644 --- a/packages/cli/test/integration/access-control/cross-project-access.test.ts +++ b/packages/cli/test/integration/access-control/cross-project-access.test.ts @@ -127,7 +127,7 @@ describe('Cross-Project Access Control Tests', () => { }); afterAll(async () => { - await testDb.truncate(['User']); + await testDb.truncate(['User', 'ProjectRelation']); await cleanupRolesAndScopes(); }); diff --git a/packages/cli/test/integration/access-control/custom-roles-functionality.test.ts b/packages/cli/test/integration/access-control/custom-roles-functionality.test.ts index a68a1764072..8741f64edb8 100644 --- a/packages/cli/test/integration/access-control/custom-roles-functionality.test.ts +++ b/packages/cli/test/integration/access-control/custom-roles-functionality.test.ts @@ -166,7 +166,7 @@ describe('Custom Role Functionality Tests', () => { }); afterAll(async () => { - await testDb.truncate(['User']); + await testDb.truncate(['User', 'ProjectRelation']); await cleanupRolesAndScopes(); }); diff --git a/packages/cli/test/integration/access-control/resource-access-matrix.test.ts b/packages/cli/test/integration/access-control/resource-access-matrix.test.ts index 94df9574ad7..91adc896065 100644 --- a/packages/cli/test/integration/access-control/resource-access-matrix.test.ts +++ b/packages/cli/test/integration/access-control/resource-access-matrix.test.ts @@ -135,7 +135,7 @@ describe('Resource Access Control Matrix Tests', () => { }); afterAll(async () => { - await testDb.truncate(['User']); + await testDb.truncate(['User', 'ProjectRelation']); await cleanupRolesAndScopes(); }); diff --git a/packages/cli/test/integration/active-workflow-manager.test.ts b/packages/cli/test/integration/active-workflow-manager.test.ts index 250b492dad7..f65b7c73639 100644 --- a/packages/cli/test/integration/active-workflow-manager.test.ts +++ b/packages/cli/test/integration/active-workflow-manager.test.ts @@ -1,5 +1,10 @@ -import { createWorkflowWithHistory, testDb, mockInstance } from '@n8n/backend-test-utils'; -import type { Project, WebhookEntity } from '@n8n/db'; +import { + createWorkflowWithHistory, + setActiveVersion, + testDb, + mockInstance, +} from '@n8n/backend-test-utils'; +import type { IWorkflowDb, Project, User, WebhookEntity } from '@n8n/db'; import { WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; @@ -43,8 +48,11 @@ const externalHooks = mockInstance(ExternalHooks); let activeWorkflowManager: ActiveWorkflowManager; -let createActiveWorkflow: () => Promise; +let createActiveWorkflow: ( + workflowOptions?: Parameters[0], +) => Promise; let createInactiveWorkflow: () => Promise; +let owner: User; beforeAll(async () => { await testDb.init(); @@ -64,15 +72,19 @@ beforeAll(async () => { await utils.initNodeTypes(nodes); - const owner = await createOwner(); - createActiveWorkflow = async () => await createWorkflowWithHistory({ active: true }, owner); + owner = await createOwner(); + createActiveWorkflow = async (workflowOptions: Partial = {}) => { + const workflow = await createWorkflowWithHistory({ active: true, ...workflowOptions }, owner); + await setActiveVersion(workflow.id, workflow.versionId); + return workflow; + }; createInactiveWorkflow = async () => await createWorkflowWithHistory({ active: false }, owner); Container.get(InstanceSettings).markAsLeader(); }); afterEach(async () => { await activeWorkflowManager.removeAll(); - await testDb.truncate(['WorkflowEntity', 'WebhookEntity']); + await testDb.truncate(['WorkflowEntity', 'WebhookEntity', 'WorkflowHistory']); jest.clearAllMocks(); }); @@ -176,7 +188,7 @@ describe('add()', () => { ); // Create a workflow which has a form trigger - const dbWorkflow = await createWorkflowWithHistory({ + const dbWorkflow = await createActiveWorkflow({ nodes: [ { id: 'uuid-1', @@ -194,7 +206,7 @@ describe('add()', () => { expect(updateWorkflowTriggerCountSpy).toHaveBeenCalledWith(dbWorkflow.id, 1); }); - test('should activate an initially inactive workflow in memory', async () => { + test('should activate a workflow after its active status changes from false to true', async () => { await activeWorkflowManager.init(); const dbWorkflow = await createInactiveWorkflow(); @@ -203,6 +215,10 @@ describe('add()', () => { // Verify it's not active in memory yet expect(activeWorkflowManager.allActiveInMemory()).toHaveLength(0); + // Simulate the workflow being activated + await setActiveVersion(dbWorkflow.id, dbWorkflow.versionId!); + await Container.get(WorkflowRepository).update(dbWorkflow.id, { active: true }); + await activeWorkflowManager.add(dbWorkflow.id, 'activate'); expect(activeWorkflowManager.allActiveInMemory()).toHaveLength(1); diff --git a/packages/cli/test/integration/commands/import.cmd.test.ts b/packages/cli/test/integration/commands/import.cmd.test.ts index d1e7e8d4906..f28aa83eaf6 100644 --- a/packages/cli/test/integration/commands/import.cmd.test.ts +++ b/packages/cli/test/integration/commands/import.cmd.test.ts @@ -48,8 +48,8 @@ test('import:workflow should import active workflow and deactivate it', async () }; expect(after).toMatchObject({ workflows: [ - expect.objectContaining({ name: 'active-workflow', active: false }), - expect.objectContaining({ name: 'inactive-workflow', active: false }), + expect.objectContaining({ name: 'active-workflow', active: false, activeVersionId: null }), + expect.objectContaining({ name: 'inactive-workflow', active: false, activeVersionId: null }), ], sharings: [ expect.objectContaining({ @@ -89,8 +89,8 @@ test('import:workflow should import active workflow from combined file and deact }; expect(after).toMatchObject({ workflows: [ - expect.objectContaining({ name: 'active-workflow', active: false }), - expect.objectContaining({ name: 'inactive-workflow', active: false }), + expect.objectContaining({ name: 'active-workflow', active: false, activeVersionId: null }), + expect.objectContaining({ name: 'inactive-workflow', active: false, activeVersionId: null }), ], sharings: [ expect.objectContaining({ @@ -127,7 +127,9 @@ test('import:workflow can import a single workflow object', async () => { sharings: await getAllSharedWorkflows(), }; expect(after).toMatchObject({ - workflows: [expect.objectContaining({ name: 'active-workflow', active: false })], + workflows: [ + expect.objectContaining({ name: 'active-workflow', active: false, activeVersionId: null }), + ], sharings: [ expect.objectContaining({ workflowId: '998', diff --git a/packages/cli/test/integration/commands/update/workflow.test.ts b/packages/cli/test/integration/commands/update/workflow.test.ts index 04c5d2102ee..ee6fab23760 100644 --- a/packages/cli/test/integration/commands/update/workflow.test.ts +++ b/packages/cli/test/integration/commands/update/workflow.test.ts @@ -1,9 +1,12 @@ import { mockInstance, testDb, - createWorkflowWithTrigger, + createWorkflowWithTriggerAndHistory, + createManyActiveWorkflows, getAllWorkflows, } from '@n8n/backend-test-utils'; +import { WorkflowRepository } from '@n8n/db'; +import { Container } from '@n8n/di'; import { UpdateWorkflowCommand } from '@/commands/update/workflow'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; @@ -13,7 +16,7 @@ mockInstance(LoadNodesAndCredentials); const command = setupTestCommand(UpdateWorkflowCommand); beforeEach(async () => { - await testDb.truncate(['WorkflowEntity']); + await testDb.truncate(['WorkflowEntity', 'WorkflowHistory']); }); test('update:workflow can activate all workflows', async () => { @@ -21,10 +24,11 @@ test('update:workflow can activate all workflows', async () => { // ARRANGE // const workflows = await Promise.all([ - createWorkflowWithTrigger({}), - createWorkflowWithTrigger({}), + createWorkflowWithTriggerAndHistory({}), + createWorkflowWithTriggerAndHistory({}), ]); - expect(workflows).toMatchObject([{ active: false }, { active: false }]); + expect(workflows[0].activeVersionId).toBeNull(); + expect(workflows[1].activeVersionId).toBeNull(); // // ACT @@ -34,19 +38,35 @@ test('update:workflow can activate all workflows', async () => { // // ASSERT // - const after = await getAllWorkflows(); - expect(after).toMatchObject([{ active: true }, { active: true }]); + // Verify activeVersionId is now set to the current versionId + const workflowRepo = Container.get(WorkflowRepository); + const workflow1 = await workflowRepo.findOne({ + where: { id: workflows[0].id }, + relations: ['activeVersion'], + }); + const workflow2 = await workflowRepo.findOne({ + where: { id: workflows[1].id }, + relations: ['activeVersion'], + }); + + expect(workflow1?.activeVersionId).toBe(workflows[0].versionId); + expect(workflow1?.activeVersion?.versionId).toBe(workflows[0].versionId); + expect(workflow2?.activeVersionId).toBe(workflows[1].versionId); + expect(workflow2?.activeVersion?.versionId).toBe(workflows[1].versionId); }); test('update:workflow can deactivate all workflows', async () => { // // ARRANGE // - const workflows = await Promise.all([ - createWorkflowWithTrigger({ active: true }), - createWorkflowWithTrigger({ active: true }), - ]); - expect(workflows).toMatchObject([{ active: true }, { active: true }]); + const workflows = await createManyActiveWorkflows(2); + + // Verify activeVersionId is set + const workflowRepo = Container.get(WorkflowRepository); + let workflow1 = await workflowRepo.findOneBy({ id: workflows[0].id }); + let workflow2 = await workflowRepo.findOneBy({ id: workflows[1].id }); + expect(workflow1?.activeVersionId).toBe(workflows[0].versionId); + expect(workflow2?.activeVersionId).toBe(workflows[1].versionId); // // ACT @@ -56,8 +76,20 @@ test('update:workflow can deactivate all workflows', async () => { // // ASSERT // - const after = await getAllWorkflows(); - expect(after).toMatchObject([{ active: false }, { active: false }]); + // Verify activeVersionId is cleared + workflow1 = await workflowRepo.findOne({ + where: { id: workflows[0].id }, + relations: ['activeVersion'], + }); + workflow2 = await workflowRepo.findOne({ + where: { id: workflows[1].id }, + relations: ['activeVersion'], + }); + + expect(workflow1?.activeVersionId).toBeNull(); + expect(workflow1?.activeVersion).toBeNull(); + expect(workflow2?.activeVersionId).toBeNull(); + expect(workflow2?.activeVersion).toBeNull(); }); test('update:workflow can activate a specific workflow', async () => { @@ -66,11 +98,10 @@ test('update:workflow can activate a specific workflow', async () => { // const workflows = ( await Promise.all([ - createWorkflowWithTrigger({ active: false }), - createWorkflowWithTrigger({ active: false }), + createWorkflowWithTriggerAndHistory(), + createWorkflowWithTriggerAndHistory(), ]) ).sort((wf1, wf2) => wf1.id.localeCompare(wf2.id)); - expect(workflows).toMatchObject([{ active: false }, { active: false }]); // // ACT @@ -81,20 +112,19 @@ test('update:workflow can activate a specific workflow', async () => { // ASSERT // const after = (await getAllWorkflows()).sort((wf1, wf2) => wf1.id.localeCompare(wf2.id)); - expect(after).toMatchObject([{ active: true }, { active: false }]); + expect(after).toMatchObject([ + { activeVersionId: workflows[0].versionId }, + { activeVersionId: null }, + ]); }); test('update:workflow can deactivate a specific workflow', async () => { // // ARRANGE // - const workflows = ( - await Promise.all([ - createWorkflowWithTrigger({ active: true }), - createWorkflowWithTrigger({ active: true }), - ]) - ).sort((wf1, wf2) => wf1.id.localeCompare(wf2.id)); - expect(workflows).toMatchObject([{ active: true }, { active: true }]); + const workflows = (await createManyActiveWorkflows(2)).sort((wf1, wf2) => + wf1.id.localeCompare(wf2.id), + ); // // ACT @@ -105,5 +135,8 @@ test('update:workflow can deactivate a specific workflow', async () => { // ASSERT // const after = (await getAllWorkflows()).sort((wf1, wf2) => wf1.id.localeCompare(wf2.id)); - expect(after).toMatchObject([{ active: false }, { active: true }]); + expect(after).toMatchObject([ + { activeVersionId: null }, + { activeVersionId: workflows[1].versionId }, + ]); }); diff --git a/packages/cli/test/integration/cta.service.test.ts b/packages/cli/test/integration/cta.service.test.ts index 645d2f85ac1..6068c78866b 100644 --- a/packages/cli/test/integration/cta.service.test.ts +++ b/packages/cli/test/integration/cta.service.test.ts @@ -1,4 +1,4 @@ -import { createManyWorkflows, testDb } from '@n8n/backend-test-utils'; +import { createManyActiveWorkflows, testDb } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; import { StatisticsNames } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -36,7 +36,7 @@ describe('CtaService', () => { ])( 'should return %p if user has %d active workflows with %d successful production executions', async (expected, numWorkflows, numExecutions) => { - const workflows = await createManyWorkflows(numWorkflows, { active: true }, user); + const workflows = await createManyActiveWorkflows(numWorkflows, {}, user); await Promise.all( workflows.map( diff --git a/packages/cli/test/integration/database/repositories/workflow.repository.test.ts b/packages/cli/test/integration/database/repositories/workflow.repository.test.ts index 510ab834bf8..56404c00200 100644 --- a/packages/cli/test/integration/database/repositories/workflow.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/workflow.repository.test.ts @@ -1,14 +1,17 @@ import { - createWorkflowWithTrigger, + createWorkflowWithTriggerAndHistory, + createWorkflowWithHistory, + createActiveWorkflow, + createManyActiveWorkflows, + createWorkflowWithActiveVersion, createWorkflow, - getAllWorkflows, testDb, } from '@n8n/backend-test-utils'; +import { GlobalConfig } from '@n8n/config'; import { WorkflowRepository, WorkflowDependencyRepository, WorkflowDependencies } from '@n8n/db'; import { Container } from '@n8n/di'; import { createTestRun } from '../../shared/db/evaluation'; -import { GlobalConfig } from '@n8n/config'; describe('WorkflowRepository', () => { beforeAll(async () => { @@ -16,7 +19,7 @@ describe('WorkflowRepository', () => { }); beforeEach(async () => { - await testDb.truncate(['WorkflowDependency', 'WorkflowEntity']); + await testDb.truncate(['WorkflowDependency', 'WorkflowEntity', 'WorkflowHistory']); }); afterAll(async () => { @@ -30,10 +33,11 @@ describe('WorkflowRepository', () => { // const workflowRepository = Container.get(WorkflowRepository); const workflows = await Promise.all([ - createWorkflowWithTrigger(), - createWorkflowWithTrigger(), + createWorkflowWithTriggerAndHistory(), + createWorkflowWithTriggerAndHistory(), ]); - expect(workflows).toMatchObject([{ active: false }, { active: false }]); + expect(workflows[0].activeVersionId).toBeNull(); + expect(workflows[1].activeVersionId).toBeNull(); // // ACT @@ -43,33 +47,79 @@ describe('WorkflowRepository', () => { // // ASSERT // - const after = await getAllWorkflows(); - expect(after).toMatchObject([{ active: true }, { active: true }]); - }); - }); + const workflow1 = await workflowRepository.findOne({ + where: { id: workflows[0].id }, + }); + const workflow2 = await workflowRepository.findOne({ + where: { id: workflows[1].id }, + }); - describe('deactivateAll', () => { - it('should deactivate all workflows', async () => { + expect(workflow1?.activeVersionId).toBe(workflows[0].versionId); + expect(workflow2?.activeVersionId).toBe(workflows[1].versionId); + }); + + it('should not change activeVersionId for already-active workflows', async () => { // // ARRANGE // const workflowRepository = Container.get(WorkflowRepository); - const workflows = await Promise.all([ - createWorkflowWithTrigger({ active: true }), - createWorkflowWithTrigger({ active: true }), - ]); - expect(workflows).toMatchObject([{ active: true }, { active: true }]); + const activeVersionId = 'old-active-version-id'; + + // Create workflow with different active and current versions + const workflow = await createWorkflowWithActiveVersion(activeVersionId, {}); + const currentVersionId = workflow.versionId; + + expect(workflow.active).toBe(true); + expect(workflow.activeVersionId).toBe(activeVersionId); + expect(workflow.versionId).toBe(currentVersionId); + + // + // ACT + // + await workflowRepository.activateAll(); + + // + // ASSERT + // + // activeVersionId should remain unchanged + const after = await workflowRepository.findOne({ + where: { id: workflow.id }, + }); + + expect(after?.activeVersionId).toBe(activeVersionId); // Unchanged + expect(after?.versionId).toBe(currentVersionId); + }); + }); + + describe('deactivateAll', () => { + it('should deactivate all workflows and clear activeVersionId', async () => { + // + // ARRANGE + // + const workflowRepository = Container.get(WorkflowRepository); + const workflows = await createManyActiveWorkflows(2); + + // Verify activeVersionId is initially set + expect(workflows[0].activeVersionId).not.toBeNull(); + expect(workflows[1].activeVersionId).not.toBeNull(); // // ACT // await workflowRepository.deactivateAll(); - // // ASSERT // - const after = await getAllWorkflows(); - expect(after).toMatchObject([{ active: false }, { active: false }]); + // Verify activeVersionId is cleared + const workflow1 = await workflowRepository.findOne({ + where: { id: workflows[0].id }, + }); + const workflow2 = await workflowRepository.findOne({ + where: { id: workflows[1].id }, + }); + + expect(workflow1?.activeVersionId).toBeNull(); + expect(workflow2?.activeVersionId).toBeNull(); }); }); @@ -79,9 +129,9 @@ describe('WorkflowRepository', () => { // ARRANGE // const workflows = await Promise.all([ - createWorkflow({ active: true }), - createWorkflow({ active: false }), - createWorkflow({ active: false }), + createActiveWorkflow(), + createWorkflowWithHistory(), + createWorkflowWithHistory(), ]); // @@ -101,9 +151,9 @@ describe('WorkflowRepository', () => { // ARRANGE // await Promise.all([ - createWorkflow({ active: true }), - createWorkflow({ active: false }), - createWorkflow({ active: true }), + createActiveWorkflow(), + createWorkflowWithHistory(), + createActiveWorkflow(), ]); // diff --git a/packages/cli/test/integration/folder/folder.controller.test.ts b/packages/cli/test/integration/folder/folder.controller.test.ts index 100bfd09232..d64d15c5b79 100644 --- a/packages/cli/test/integration/folder/folder.controller.test.ts +++ b/packages/cli/test/integration/folder/folder.controller.test.ts @@ -8,6 +8,7 @@ import { linkUserToProject, testDb, mockInstance, + createActiveWorkflow, } from '@n8n/backend-test-utils'; import type { Project, User } from '@n8n/db'; import { FolderRepository, ProjectRepository, WorkflowRepository } from '@n8n/db'; @@ -388,7 +389,6 @@ describe('GET /projects/:projectId/folders/:folderId/credentials', () => { { name: 'Test Workflow', parentFolder: folder, - active: false, nodes: [ { parameters: {}, @@ -480,7 +480,6 @@ describe('GET /projects/:projectId/folders/:folderId/credentials', () => { { name: 'Test Workflow', parentFolder: folder, - active: false, nodes: [ { parameters: {}, @@ -1093,8 +1092,8 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => { }); // Create workflows in the folders - const workflow1 = await createWorkflow({ parentFolder: rootFolder, active: false }, owner); - const workflow2 = await createWorkflow({ parentFolder: childFolder, active: true }, owner); + const workflow1 = await createWorkflow({ parentFolder: rootFolder }, owner); + const workflow2 = await createActiveWorkflow({ parentFolder: childFolder }, owner); await authOwnerAgent.delete(`/projects/${project.id}/folders/${rootFolder.id}`); @@ -1115,6 +1114,7 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => { expect(workflow1InDb?.isArchived).toBe(true); expect(workflow1InDb?.parentFolder).toBe(null); expect(workflow1InDb?.active).toBe(false); + expect(workflow1InDb?.activeVersionId).toBeNull(); const workflow2InDb = await workflowRepository.findOne({ where: { id: workflow2.id }, @@ -1124,6 +1124,7 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => { expect(workflow2InDb?.isArchived).toBe(true); expect(workflow2InDb?.parentFolder).toBe(null); expect(workflow2InDb?.active).toBe(false); + expect(workflow2InDb?.activeVersionId).toBeNull(); }); test('should transfer folder contents when transferToFolderId is specified', async () => { @@ -1823,7 +1824,7 @@ describe('PUT /projects/:projectId/folders/:folderId/transfer', () => { const sourceFolder1 = await createFolder(sourceProject, { name: 'Source Folder 1' }); - await createWorkflow({ active: true, parentFolder: sourceFolder1 }, destinationProject); + await createActiveWorkflow({ parentFolder: sourceFolder1 }, destinationProject); await testServer .authAgentFor(member) @@ -1856,7 +1857,7 @@ describe('PUT /projects/:projectId/folders/:folderId/transfer', () => { const sourceFolder1 = await createFolder(sourceProject, { name: 'Source Folder 1' }); - await createWorkflow({ active: true }, destinationProject); + await createActiveWorkflow({}, destinationProject); await testServer .authAgentFor(member) @@ -2599,7 +2600,7 @@ describe('PUT /projects/:projectId/folders/:folderId/transfer', () => { parentFolder: sourceFolder1, }); - await createWorkflow({ active: true, parentFolder: sourceFolder1 }, sourceProject); + await createActiveWorkflow({ parentFolder: sourceFolder1 }, sourceProject); await createWorkflow({ parentFolder: sourceFolder2 }, sourceProject); activeWorkflowManager.add.mockRejectedValue(new ApplicationError('Oh no!')); diff --git a/packages/cli/test/integration/import.service.test.ts b/packages/cli/test/integration/import.service.test.ts index a7c9b346eab..77a43c2b23b 100644 --- a/packages/cli/test/integration/import.service.test.ts +++ b/packages/cli/test/integration/import.service.test.ts @@ -5,6 +5,7 @@ import { getWorkflowById, newWorkflow, testDb, + createActiveWorkflow, } from '@n8n/backend-test-utils'; import { DatabaseConfig } from '@n8n/config'; import type { Project, User } from '@n8n/db'; @@ -120,7 +121,7 @@ describe('ImportService', () => { }); test('should deactivate imported workflow if active', async () => { - const workflowToImport = await createWorkflow({ active: true }); + const workflowToImport = await createActiveWorkflow(); await importService.importWorkflows([workflowToImport], ownerPersonalProject.id); @@ -129,6 +130,7 @@ describe('ImportService', () => { if (!dbWorkflow) fail('Expected to find workflow'); expect(dbWorkflow.active).toBe(false); + expect(dbWorkflow.activeVersionId).toBeNull(); }); test('should leave intact new-format credentials', async () => { @@ -227,7 +229,7 @@ describe('ImportService', () => { }); test('should remove workflow from ActiveWorkflowManager when workflow has ID', async () => { - const workflowWithId = await createWorkflow({ active: true }); + const workflowWithId = await createActiveWorkflow(); await importService.importWorkflows([workflowWithId], ownerPersonalProject.id); expect(mockActiveWorkflowManager.remove).toHaveBeenCalledWith(workflowWithId.id); diff --git a/packages/cli/test/integration/license-metrics.repository.test.ts b/packages/cli/test/integration/license-metrics.repository.test.ts index 7cb84f9c23f..4472e3c825e 100644 --- a/packages/cli/test/integration/license-metrics.repository.test.ts +++ b/packages/cli/test/integration/license-metrics.repository.test.ts @@ -1,4 +1,4 @@ -import { createManyWorkflows, testDb } from '@n8n/backend-test-utils'; +import { createManyActiveWorkflows, createManyWorkflows, testDb } from '@n8n/backend-test-utils'; import { StatisticsNames, LicenseMetricsRepository, WorkflowStatisticsRepository } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -33,7 +33,7 @@ describe('LicenseMetricsRepository', () => { describe('getLicenseRenewalMetrics', () => { test('should return license renewal metrics', async () => { - const [firstWorkflow, secondWorkflow] = await createManyWorkflows(2, { active: false }); + const [firstWorkflow, secondWorkflow] = await createManyWorkflows(2); await Promise.all([ createOwner(), @@ -42,7 +42,7 @@ describe('LicenseMetricsRepository', () => { createMember(), createUser({ disabled: true }), createManyCredentials(2), - createManyWorkflows(3, { active: true }), + createManyActiveWorkflows(3), ]); await Promise.all([ @@ -85,7 +85,8 @@ describe('LicenseMetricsRepository', () => { }); test('should handle zero execution statistics correctly', async () => { - await Promise.all([createOwner(), createManyWorkflows(3, { active: true })]); + const owner = await createOwner(); + await createManyActiveWorkflows(3, {}, owner); const metrics = await licenseMetricsRepository.getLicenseRenewalMetrics(); diff --git a/packages/cli/test/integration/prometheus-metrics.test.ts b/packages/cli/test/integration/prometheus-metrics.test.ts index 34fe2b2e290..7c0d56e8d7d 100644 --- a/packages/cli/test/integration/prometheus-metrics.test.ts +++ b/packages/cli/test/integration/prometheus-metrics.test.ts @@ -1,4 +1,4 @@ -import { createWorkflow, newWorkflow } from '@n8n/backend-test-utils'; +import { createActiveWorkflow } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import { WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -361,8 +361,7 @@ describe('PrometheusMetricsService', () => { expect(lines).toContain('n8n_test_active_workflow_count 0'); - const workflow = newWorkflow({ active: true }); - await createWorkflow(workflow); + await createActiveWorkflow({}); const workflowRepository = Container.get(WorkflowRepository); const activeWorkflowCount = await workflowRepository.getActiveCount(); diff --git a/packages/cli/test/integration/public-api/workflows.test.ts b/packages/cli/test/integration/public-api/workflows.test.ts index 920d8e8f3e5..b4c0f115f26 100644 --- a/packages/cli/test/integration/public-api/workflows.test.ts +++ b/packages/cli/test/integration/public-api/workflows.test.ts @@ -4,6 +4,7 @@ import { createWorkflowWithTriggerAndHistory, testDb, mockInstance, + createActiveWorkflow, } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import type { Project, TagEntity, User } from '@n8n/db'; @@ -110,6 +111,7 @@ describe('GET /workflows', () => { id, connections, active, + activeVersionId, staticData, nodes, settings, @@ -123,6 +125,7 @@ describe('GET /workflows', () => { expect(name).toBeDefined(); expect(connections).toBeDefined(); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(staticData).toBeDefined(); expect(nodes).toBeDefined(); expect(tags).toBeDefined(); @@ -161,6 +164,7 @@ describe('GET /workflows', () => { id, connections, active, + activeVersionId, staticData, nodes, settings, @@ -174,6 +178,7 @@ describe('GET /workflows', () => { expect(name).toBeDefined(); expect(connections).toBeDefined(); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(staticData).toBeDefined(); expect(nodes).toBeDefined(); expect(tags).toBeDefined(); @@ -203,6 +208,7 @@ describe('GET /workflows', () => { id, connections, active, + activeVersionId, staticData, nodes, settings, @@ -216,6 +222,7 @@ describe('GET /workflows', () => { expect(name).toBeDefined(); expect(connections).toBeDefined(); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(staticData).toBeDefined(); expect(nodes).toBeDefined(); expect(settings).toBeDefined(); @@ -244,8 +251,18 @@ describe('GET /workflows', () => { expect(response.body.data.length).toBe(2); for (const workflow of response.body.data) { - const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = - workflow; + const { + id, + connections, + active, + activeVersionId, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + } = workflow; expect(id).toBeDefined(); expect([workflow1.id, workflow2.id].includes(id)).toBe(true); @@ -253,6 +270,7 @@ describe('GET /workflows', () => { expect(name).toBeDefined(); expect(connections).toBeDefined(); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(staticData).toBeDefined(); expect(nodes).toBeDefined(); expect(settings).toBeDefined(); @@ -324,6 +342,7 @@ describe('GET /workflows', () => { id, connections, active, + activeVersionId, staticData, nodes, settings, @@ -337,6 +356,7 @@ describe('GET /workflows', () => { expect(name).toBe(workflowName); expect(connections).toBeDefined(); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(staticData).toBeDefined(); expect(nodes).toBeDefined(); expect(settings).toBeDefined(); @@ -365,6 +385,7 @@ describe('GET /workflows', () => { id, connections, active, + activeVersionId, staticData, nodes, settings, @@ -378,6 +399,7 @@ describe('GET /workflows', () => { expect(name).toBeDefined(); expect(connections).toBeDefined(); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(staticData).toBeDefined(); expect(nodes).toBeDefined(); expect(tags).toBeDefined(); @@ -427,6 +449,57 @@ describe('GET /workflows', () => { expect(pinData).not.toBeDefined(); } }); + + test('should return activeVersion for all workflows', async () => { + const inactiveWorkflow = await createWorkflow({}, member); + const activeWorkflow = await createWorkflowWithTriggerAndHistory({}, member); + + await authMemberAgent.post(`/workflows/${activeWorkflow.id}/activate`); + + const response = await authMemberAgent.get('/workflows'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + + const inactiveInResponse = response.body.data.find( + (w: { id: string }) => w.id === inactiveWorkflow.id, + ); + const activeInResponse = response.body.data.find( + (w: { id: string }) => w.id === activeWorkflow.id, + ); + + // Inactive workflow should have null activeVersion + expect(inactiveInResponse).toBeDefined(); + expect(inactiveInResponse.activeVersionId).toBeNull(); + + // Active workflow should have populated activeVersion + expect(activeInResponse).toBeDefined(); + expect(activeInResponse.active).toBe(true); + expect(activeInResponse.activeVersion).toBeDefined(); + expect(activeInResponse.activeVersion).not.toBeNull(); + expect(activeInResponse.activeVersion.versionId).toBe(activeWorkflow.versionId); + expect(activeInResponse.activeVersion.nodes).toEqual(activeWorkflow.nodes); + expect(activeInResponse.activeVersion.connections).toEqual(activeWorkflow.connections); + }); + + test('should return activeVersion when filtering by active=true', async () => { + await createWorkflow({}, member); + const activeWorkflow = await createWorkflowWithTriggerAndHistory({}, member); + + await authMemberAgent.post(`/workflows/${activeWorkflow.id}/activate`); + + const response = await authMemberAgent.get('/workflows?active=true'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(1); + + const workflow = response.body.data[0]; + expect(workflow.id).toBe(activeWorkflow.id); + expect(workflow.active).toBe(true); + expect(workflow.activeVersion).toBeDefined(); + expect(workflow.activeVersion).not.toBeNull(); + expect(workflow.activeVersion.versionId).toBe(activeWorkflow.versionId); + }); }); describe('GET /workflows/:id', () => { @@ -451,6 +524,7 @@ describe('GET /workflows/:id', () => { id, connections, active, + activeVersionId, staticData, nodes, settings, @@ -464,6 +538,7 @@ describe('GET /workflows/:id', () => { expect(name).toEqual(workflow.name); expect(connections).toEqual(workflow.connections); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(staticData).toEqual(workflow.staticData); expect(nodes).toEqual(workflow.nodes); expect(tags).toEqual([]); @@ -480,13 +555,24 @@ describe('GET /workflows/:id', () => { expect(response.statusCode).toBe(200); - const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = - response.body; + const { + id, + connections, + active, + activeVersionId, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + } = response.body; expect(id).toEqual(workflow.id); expect(name).toEqual(workflow.name); expect(connections).toEqual(workflow.connections); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(staticData).toEqual(workflow.staticData); expect(nodes).toEqual(workflow.nodes); expect(settings).toEqual(workflow.settings); @@ -513,6 +599,34 @@ describe('GET /workflows/:id', () => { expect(pinData).not.toBeDefined(); }); + + test('should return activeVersion as null for inactive workflow', async () => { + const workflow = await createWorkflow({}, member); + + const response = await authMemberAgent.get(`/workflows/${workflow.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body.active).toBe(false); + expect(response.body.activeVersionId).toBe(null); + expect(response.body.activeVersion).toBeNull(); + }); + + test('should return activeVersion for active workflow', async () => { + const workflow = await createWorkflowWithTriggerAndHistory({}, member); + + await authMemberAgent.post(`/workflows/${workflow.id}/activate`); + + const response = await authMemberAgent.get(`/workflows/${workflow.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body.active).toBe(true); + expect(response.body.activeVersionId).toBe(workflow.versionId); + expect(response.body.activeVersion).toBeDefined(); + expect(response.body.activeVersion).not.toBeNull(); + expect(response.body.activeVersion.versionId).toBe(workflow.versionId); + expect(response.body.activeVersion.nodes).toEqual(workflow.nodes); + expect(response.body.activeVersion.connections).toEqual(workflow.connections); + }); }); describe('DELETE /workflows/:id', () => { @@ -533,13 +647,24 @@ describe('DELETE /workflows/:id', () => { expect(response.statusCode).toBe(200); - const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = - response.body; + const { + id, + connections, + active, + activeVersionId, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + } = response.body; expect(id).toEqual(workflow.id); expect(name).toEqual(workflow.name); expect(connections).toEqual(workflow.connections); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(staticData).toEqual(workflow.staticData); expect(nodes).toEqual(workflow.nodes); expect(settings).toEqual(workflow.settings); @@ -562,13 +687,24 @@ describe('DELETE /workflows/:id', () => { expect(response.statusCode).toBe(200); - const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = - response.body; + const { + id, + connections, + active, + activeVersionId, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + } = response.body; expect(id).toEqual(workflow.id); expect(name).toEqual(workflow.name); expect(connections).toEqual(workflow.connections); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(staticData).toEqual(workflow.staticData); expect(nodes).toEqual(workflow.nodes); expect(settings).toEqual(workflow.settings); @@ -630,13 +766,24 @@ describe('POST /workflows/:id/activate', () => { expect(response.statusCode).toBe(200); - const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = - response.body; + const { + id, + connections, + active, + activeVersionId, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + } = response.body; expect(id).toEqual(workflow.id); expect(name).toEqual(workflow.name); expect(connections).toEqual(workflow.connections); expect(active).toBe(true); + expect(activeVersionId).toBe(workflow.versionId); expect(staticData).toEqual(workflow.staticData); expect(nodes).toEqual(workflow.nodes); expect(settings).toEqual(workflow.settings); @@ -652,24 +799,58 @@ describe('POST /workflows/:id/activate', () => { relations: ['workflow'], }); - expect(sharedWorkflow?.workflow.active).toBe(true); + expect(sharedWorkflow?.workflow.activeVersionId).toBe(workflow.versionId); // check whether the workflow is on the active workflow runner expect(await activeWorkflowManager.isActive(workflow.id)).toBe(true); }); + test('should set activeVersionId when activating workflow', async () => { + const workflow = await createWorkflowWithTriggerAndHistory({}, member); + + const response = await authMemberAgent.post(`/workflows/${workflow.id}/activate`); + + expect(response.statusCode).toBe(200); + expect(response.body.active).toBe(true); + expect(response.body.activeVersionId).toBe(workflow.versionId); + + const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ + where: { + projectId: memberPersonalProject.id, + workflowId: workflow.id, + }, + relations: ['workflow', 'workflow.activeVersion'], + }); + + expect(sharedWorkflow?.workflow.activeVersionId).toBe(workflow.versionId); + expect(sharedWorkflow?.workflow.activeVersion?.versionId).toBe(workflow.versionId); + expect(sharedWorkflow?.workflow.activeVersion?.nodes).toEqual(workflow.nodes); + expect(sharedWorkflow?.workflow.activeVersion?.connections).toEqual(workflow.connections); + }); + test('should set non-owned workflow as active when owner', async () => { const workflow = await createWorkflowWithTriggerAndHistory({}, member); const response = await authMemberAgent.post(`/workflows/${workflow.id}/activate`).expect(200); - const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = - response.body; + const { + id, + connections, + active, + activeVersionId, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + } = response.body; expect(id).toEqual(workflow.id); expect(name).toEqual(workflow.name); expect(connections).toEqual(workflow.connections); expect(active).toBe(true); + expect(activeVersionId).toBe(workflow.versionId); expect(staticData).toEqual(workflow.staticData); expect(nodes).toEqual(workflow.nodes); expect(settings).toEqual(workflow.settings); @@ -694,7 +875,7 @@ describe('POST /workflows/:id/activate', () => { relations: ['workflow'], }); - expect(sharedWorkflow?.workflow.active).toBe(true); + expect(sharedWorkflow?.workflow.activeVersionId).toBe(workflow.versionId); // check whether the workflow is on the active workflow runner expect(await activeWorkflowManager.isActive(workflow.id)).toBe(true); @@ -726,13 +907,24 @@ describe('POST /workflows/:id/deactivate', () => { `/workflows/${workflow.id}/deactivate`, ); - const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = - workflowDeactivationResponse.body; + const { + id, + connections, + active, + activeVersionId, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + } = workflowDeactivationResponse.body; expect(id).toEqual(workflow.id); expect(name).toEqual(workflow.name); expect(connections).toEqual(workflow.connections); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(staticData).toEqual(workflow.staticData); expect(nodes).toEqual(workflow.nodes); expect(settings).toEqual(workflow.settings); @@ -749,11 +941,42 @@ describe('POST /workflows/:id/deactivate', () => { }); // check whether the workflow is deactivated in the database - expect(sharedWorkflow?.workflow.active).toBe(false); + expect(sharedWorkflow?.workflow.activeVersionId).toBeNull(); expect(await activeWorkflowManager.isActive(workflow.id)).toBe(false); }); + test('should clear activeVersionId when deactivating workflow', async () => { + const workflow = await createWorkflowWithTriggerAndHistory({}, member); + + await authMemberAgent.post(`/workflows/${workflow.id}/activate`); + let sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ + where: { + projectId: memberPersonalProject.id, + workflowId: workflow.id, + }, + relations: ['workflow'], + }); + + expect(sharedWorkflow?.workflow.activeVersionId).toBe(workflow.versionId); + + const deactivateResponse = await authMemberAgent.post(`/workflows/${workflow.id}/deactivate`); + + expect(deactivateResponse.statusCode).toBe(200); + expect(deactivateResponse.body.active).toBe(false); + expect(deactivateResponse.body.activeVersionId).toBe(null); + + sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ + where: { + projectId: memberPersonalProject.id, + workflowId: workflow.id, + }, + relations: ['workflow'], + }); + + expect(sharedWorkflow?.workflow.activeVersionId).toBeNull(); + }); + test('should deactivate non-owned workflow when owner', async () => { const workflow = await createWorkflowWithTriggerAndHistory({}, member); @@ -763,13 +986,24 @@ describe('POST /workflows/:id/deactivate', () => { `/workflows/${workflow.id}/deactivate`, ); - const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = - workflowDeactivationResponse.body; + const { + id, + connections, + active, + activeVersionId, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + } = workflowDeactivationResponse.body; expect(id).toEqual(workflow.id); expect(name).toEqual(workflow.name); expect(connections).toEqual(workflow.connections); expect(active).toBe(false); + expect(activeVersionId).toBe(null); expect(staticData).toEqual(workflow.staticData); expect(nodes).toEqual(workflow.nodes); expect(settings).toEqual(workflow.settings); @@ -794,7 +1028,7 @@ describe('POST /workflows/:id/deactivate', () => { relations: ['workflow'], }); - expect(sharedWorkflow?.workflow.active).toBe(false); + expect(sharedWorkflow?.workflow.activeVersionId).toBeNull(); expect(await activeWorkflowManager.isActive(workflow.id)).toBe(false); }); @@ -842,16 +1076,27 @@ describe('POST /workflows', () => { expect(response.statusCode).toBe(200); - const { id, name, nodes, connections, staticData, active, settings, createdAt, updatedAt } = - response.body; + const { + id, + name, + nodes, + connections, + staticData, + active, + activeVersionId, + settings, + createdAt, + updatedAt, + } = response.body; expect(id).toBeDefined(); expect(name).toBe(payload.name); expect(connections).toEqual(payload.connections); expect(settings).toEqual(payload.settings); + expect(active).toBe(false); expect(staticData).toEqual(payload.staticData); expect(nodes).toEqual(payload.nodes); - expect(active).toBe(false); + expect(activeVersionId).toBe(null); expect(createdAt).toBeDefined(); expect(updatedAt).toEqual(createdAt); @@ -1042,8 +1287,18 @@ describe('PUT /workflows/:id', () => { const response = await authMemberAgent.put(`/workflows/${workflow.id}`).send(payload); - const { id, name, nodes, connections, staticData, active, settings, createdAt, updatedAt } = - response.body; + const { + id, + name, + nodes, + connections, + staticData, + active, + activeVersionId, + settings, + createdAt, + updatedAt, + } = response.body; expect(response.statusCode).toBe(200); @@ -1051,9 +1306,10 @@ describe('PUT /workflows/:id', () => { expect(name).toBe(payload.name); expect(connections).toEqual(payload.connections); expect(settings).toEqual(payload.settings); + expect(active).toBe(false); expect(staticData).toMatchObject(JSON.parse(payload.staticData)); expect(nodes).toEqual(payload.nodes); - expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(createdAt).toBe(workflow.createdAt.toISOString()); expect(updatedAt).not.toBe(workflow.updatedAt.toISOString()); @@ -1126,6 +1382,119 @@ describe('PUT /workflows/:id', () => { expect(historyVersion!.nodes).toEqual(payload.nodes); }); + test('should update activeVersionId when updating an active workflow', async () => { + const workflow = await createActiveWorkflow({}, member); + + const updatedPayload = { + name: 'Updated active workflow', + nodes: [ + { + id: 'uuid-updated', + parameters: { triggerTimes: { item: [{ mode: 'everyMinute' }] } }, + name: 'Updated Cron', + type: 'n8n-nodes-base.cron', + typeVersion: 1, + position: [300, 400], + }, + ], + connections: {}, + staticData: workflow.staticData, + settings: workflow.settings, + }; + + const updateResponse = await authMemberAgent + .put(`/workflows/${workflow.id}`) + .send(updatedPayload); + + expect(updateResponse.statusCode).toBe(200); + + await authMemberAgent.post(`/workflows/${workflow.id}/activate`); + + const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ + where: { + projectId: memberPersonalProject.id, + workflowId: workflow.id, + }, + relations: ['workflow', 'workflow.activeVersion'], + }); + + expect(sharedWorkflow?.workflow.activeVersionId).toBe(sharedWorkflow?.workflow.versionId); + expect(sharedWorkflow?.workflow.activeVersionId).not.toBe(workflow.activeVersionId); + expect(sharedWorkflow?.workflow.activeVersion?.nodes).toEqual(updatedPayload.nodes); + }); + + test('should not update activeVersionId when updating an inactive workflow', async () => { + const workflow = await createWorkflow({}, member); + + // Update workflow without activating it + const updatedPayload = { + name: 'Updated inactive workflow', + nodes: [ + { + id: 'uuid-inactive', + parameters: {}, + name: 'Start Node', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [200, 300], + }, + ], + connections: {}, + staticData: workflow.staticData, + settings: workflow.settings, + }; + + const updateResponse = await authMemberAgent + .put(`/workflows/${workflow.id}`) + .send(updatedPayload); + + expect(updateResponse.statusCode).toBe(200); + expect(updateResponse.body.active).toBe(false); + expect(updateResponse.body.activeVersionId).toBeNull(); + + // Verify activeVersion is still null + const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ + where: { + projectId: memberPersonalProject.id, + workflowId: workflow.id, + }, + relations: ['workflow'], + }); + + expect(sharedWorkflow?.workflow.activeVersionId).toBeNull(); + }); + + test('should not allow setting active field via PUT request', async () => { + const workflow = await createWorkflowWithTriggerAndHistory({}, member); + + const updatePayload = { + name: 'Try to activate via update', + nodes: workflow.nodes, + connections: workflow.connections, + staticData: workflow.staticData, + settings: workflow.settings, + active: true, + }; + + const updateResponse = await authMemberAgent + .put(`/workflows/${workflow.id}`) + .send(updatePayload); + + expect(updateResponse.statusCode).toBe(400); + expect(updateResponse.body.message).toContain('active'); + expect(updateResponse.body.message).toContain('read-only'); + + const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ + where: { + projectId: memberPersonalProject.id, + workflowId: workflow.id, + }, + relations: ['workflow'], + }); + + expect(sharedWorkflow?.workflow.activeVersionId).toBeNull(); + }); + test('should update non-owned workflow if owner', async () => { const workflow = await createWorkflow({}, member); @@ -1165,8 +1534,18 @@ describe('PUT /workflows/:id', () => { const response = await authMemberAgent.put(`/workflows/${workflow.id}`).send(payload); - const { id, name, nodes, connections, staticData, active, settings, createdAt, updatedAt } = - response.body; + const { + id, + name, + nodes, + connections, + staticData, + active, + activeVersionId, + settings, + createdAt, + updatedAt, + } = response.body; expect(response.statusCode).toBe(200); @@ -1174,9 +1553,10 @@ describe('PUT /workflows/:id', () => { expect(name).toBe(payload.name); expect(connections).toEqual(payload.connections); expect(settings).toEqual(payload.settings); + expect(active).toBe(false); expect(staticData).toMatchObject(JSON.parse(payload.staticData)); expect(nodes).toEqual(payload.nodes); - expect(active).toBe(false); + expect(activeVersionId).toBeNull(); expect(createdAt).toBe(workflow.createdAt.toISOString()); expect(updatedAt).not.toBe(workflow.updatedAt.toISOString()); diff --git a/packages/cli/test/integration/security-audit/credentials-risk-reporter.test.ts b/packages/cli/test/integration/security-audit/credentials-risk-reporter.test.ts index e48418133cd..7bbfd0eb094 100644 --- a/packages/cli/test/integration/security-audit/credentials-risk-reporter.test.ts +++ b/packages/cli/test/integration/security-audit/credentials-risk-reporter.test.ts @@ -1,4 +1,4 @@ -import { testDb } from '@n8n/backend-test-utils'; +import { createActiveWorkflow, createWorkflowWithHistory, testDb } from '@n8n/backend-test-utils'; import type { SecurityConfig } from '@n8n/config'; import { generateNanoId, @@ -46,12 +46,9 @@ test('should report credentials not in any use', async () => { }; const workflowDetails = { - id: generateNanoId(), name: 'My Test Workflow', - active: false, connections: {}, nodeTypes: {}, - versionId: uuid(), nodes: [ { id: uuid(), @@ -59,13 +56,14 @@ test('should report credentials not in any use', async () => { type: 'n8n-nodes-base.slack', typeVersion: 1, position: [0, 0] as [number, number], + parameters: {}, }, ], }; await Promise.all([ Container.get(CredentialsRepository).save(credentialDetails), - Container.get(WorkflowRepository).save(workflowDetails), + createWorkflowWithHistory(workflowDetails), ]); const testAudit = await securityAuditService.run(['credentials']); @@ -94,12 +92,9 @@ test('should report credentials not in active use', async () => { const credential = await Container.get(CredentialsRepository).save(credentialDetails); const workflowDetails = { - id: generateNanoId(), name: 'My Test Workflow', - active: false, connections: {}, nodeTypes: {}, - versionId: uuid(), nodes: [ { id: uuid(), @@ -107,11 +102,12 @@ test('should report credentials not in active use', async () => { type: 'n8n-nodes-base.slack', typeVersion: 1, position: [0, 0] as [number, number], + parameters: {}, }, ], }; - await Container.get(WorkflowRepository).save(workflowDetails); + await createWorkflowWithHistory(workflowDetails); const testAudit = await securityAuditService.run(['credentials']); @@ -139,12 +135,9 @@ test('should report credential in not recently executed workflow', async () => { const credential = await Container.get(CredentialsRepository).save(credentialDetails); const workflowDetails = { - id: generateNanoId(), name: 'My Test Workflow', - active: false, connections: {}, nodeTypes: {}, - versionId: uuid(), nodes: [ { id: uuid(), @@ -158,11 +151,12 @@ test('should report credential in not recently executed workflow', async () => { name: credential.name, }, }, + parameters: {}, }, ], }; - const workflow = await Container.get(WorkflowRepository).save(workflowDetails); + const workflow = await createWorkflowWithHistory(workflowDetails); const date = new Date(); date.setDate(date.getDate() - securityConfig.daysAbandonedWorkflow - 1); @@ -209,12 +203,9 @@ test('should not report credentials in recently executed workflow', async () => const credential = await Container.get(CredentialsRepository).save(credentialDetails); const workflowDetails = { - id: generateNanoId(), name: 'My Test Workflow', - active: true, connections: {}, nodeTypes: {}, - versionId: uuid(), nodes: [ { id: uuid(), @@ -228,11 +219,12 @@ test('should not report credentials in recently executed workflow', async () => name: credential.name, }, }, + parameters: {}, }, ], }; - const workflow = await Container.get(WorkflowRepository).save(workflowDetails); + const workflow = await createActiveWorkflow(workflowDetails); const date = new Date(); date.setDate(date.getDate() - securityConfig.daysAbandonedWorkflow + 1); diff --git a/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts b/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts index 5569243d404..110a4d41bd5 100644 --- a/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts +++ b/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts @@ -1,6 +1,6 @@ -import { testDb } from '@n8n/backend-test-utils'; +import { createActiveWorkflow, testDb } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; -import { generateNanoId, WorkflowRepository } from '@n8n/db'; +import { WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import { NodeConnectionTypes } from 'n8n-workflow'; @@ -39,13 +39,9 @@ afterAll(async () => { test('should report webhook lacking authentication', async () => { const targetNodeId = uuid(); - const details = { - id: generateNanoId(), + await createActiveWorkflow({ name: 'My Test Workflow', - active: true, - nodeTypes: {}, connections: {}, - versionId: uuid(), nodes: [ { parameters: { @@ -60,9 +56,7 @@ test('should report webhook lacking authentication', async () => { webhookId: uuid(), }, ], - }; - - await Container.get(WorkflowRepository).save(details); + }); const testAudit = await securityAuditService.run(['instance']); @@ -83,13 +77,9 @@ test('should report webhook lacking authentication', async () => { test('should not report webhooks having basic or header auth', async () => { const promises = ['basicAuth', 'headerAuth'].map(async (authType) => { - const details = { - id: generateNanoId(), + return await createActiveWorkflow({ name: 'My Test Workflow', - active: true, - nodeTypes: {}, connections: {}, - versionId: uuid(), nodes: [ { parameters: { @@ -105,9 +95,7 @@ test('should not report webhooks having basic or header auth', async () => { webhookId: uuid(), }, ], - }; - - return await Container.get(WorkflowRepository).save(details); + }); }); await Promise.all(promises); @@ -129,12 +117,8 @@ test('should not report webhooks having basic or header auth', async () => { test('should not report webhooks validated by direct children', async () => { const promises = [...WEBHOOK_VALIDATOR_NODE_TYPES].map(async (nodeType) => { - const details = { - id: generateNanoId(), + return await createActiveWorkflow({ name: 'My Test Workflow', - active: true, - nodeTypes: {}, - versionId: uuid(), nodes: [ { parameters: { @@ -154,6 +138,7 @@ test('should not report webhooks validated by direct children', async () => { type: nodeType, typeVersion: 1, position: [0, 0] as [number, number], + parameters: {}, }, ], connections: { @@ -169,9 +154,7 @@ test('should not report webhooks validated by direct children', async () => { ], }, }, - }; - - return await Container.get(WorkflowRepository).save(details); + }); }); await Promise.all(promises); diff --git a/packages/cli/test/integration/services/role.service.test.ts b/packages/cli/test/integration/services/role.service.test.ts index 71f3d85629f..5896a45ca67 100644 --- a/packages/cli/test/integration/services/role.service.test.ts +++ b/packages/cli/test/integration/services/role.service.test.ts @@ -53,7 +53,7 @@ afterAll(async () => { }); afterEach(async () => { - await testDb.truncate(['User']); + await testDb.truncate(['User', 'ProjectRelation']); await cleanupRolesAndScopes(); }); diff --git a/packages/cli/test/integration/shared/utils/index.ts b/packages/cli/test/integration/shared/utils/index.ts index 4fb3f734990..a6fbc08f8e9 100644 --- a/packages/cli/test/integration/shared/utils/index.ts +++ b/packages/cli/test/integration/shared/utils/index.ts @@ -8,6 +8,7 @@ import { InstanceSettings, UnrecognizedNodeTypeError, type DirectoryLoader, + type ErrorReporter, } from 'n8n-core'; import { Ftp } from 'n8n-nodes-base/credentials/Ftp.credentials'; import { GithubApi } from 'n8n-nodes-base/credentials/GithubApi.credentials'; @@ -115,7 +116,8 @@ export async function initBinaryDataService(mode: 'default' | 'filesystem' = 'de availableModes: [mode], localStoragePath: '', }); - const binaryDataService = new BinaryDataService(config); + const errorReporter = mock(); + const binaryDataService = new BinaryDataService(config, errorReporter); await binaryDataService.init(); Container.set(BinaryDataService, binaryDataService); } @@ -194,6 +196,7 @@ export function makeWorkflow(options?: { workflow.name = 'My Workflow'; workflow.active = false; + workflow.activeVersionId = null; workflow.connections = {}; workflow.nodes = [node]; diff --git a/packages/cli/test/integration/webhooks.api.test.ts b/packages/cli/test/integration/webhooks.api.test.ts index dff68754a8a..dceb9bdceb1 100644 --- a/packages/cli/test/integration/webhooks.api.test.ts +++ b/packages/cli/test/integration/webhooks.api.test.ts @@ -1,7 +1,6 @@ -import { createWorkflow, testDb, mockInstance } from '@n8n/backend-test-utils'; +import { testDb, mockInstance, createActiveWorkflow } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; import { readFileSync } from 'fs'; -import { mock } from 'jest-mock-extended'; import { type INode, type IWorkflowBase, @@ -66,11 +65,15 @@ class WebhookTestingNode implements INodeType { describe('Webhook API', () => { const nodeInstance = new WebhookTestingNode(); - const node = mock({ + const node: INode = { + id: 'webhook-node-1', name: 'Webhook', type: nodeInstance.description.name, + typeVersion: 1, + position: [0, 0], + parameters: {}, webhookId: '5ccef736-be16-4d10-b7fb-feed7a61ff22', - }); + }; const workflowData = { active: true, nodes: [node] } as IWorkflowBase; const nodeTypes = mockInstance(NodeTypes); @@ -91,7 +94,7 @@ describe('Webhook API', () => { beforeEach(async () => { await testDb.truncate(['WorkflowEntity']); - await createWorkflow(workflowData, user); + await createActiveWorkflow(workflowData, user); await initActiveWorkflowManager(); }); diff --git a/packages/cli/test/integration/workflow-history-manager.test.ts b/packages/cli/test/integration/workflow-history-manager.test.ts index 8005bb6ca0b..79e48454d83 100644 --- a/packages/cli/test/integration/workflow-history-manager.test.ts +++ b/packages/cli/test/integration/workflow-history-manager.test.ts @@ -1,4 +1,9 @@ -import { createWorkflow, testDb, mockInstance } from '@n8n/backend-test-utils'; +import { + createWorkflow, + testDb, + mockInstance, + createActiveWorkflow, +} from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import { WorkflowHistoryRepository, WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -24,7 +29,7 @@ describe('Workflow History Manager', () => { }); beforeEach(async () => { - await testDb.truncate(['WorkflowEntity']); + await testDb.truncate(['WorkflowEntity', 'WorkflowHistory']); jest.clearAllMocks(); globalConfig.workflowHistory.pruneTime = -1; @@ -92,8 +97,8 @@ describe('Workflow History Manager', () => { test('should not prune current versions', async () => { globalConfig.workflowHistory.pruneTime = 24; - const activeWorkflow = await createWorkflow({ active: true }); - const inactiveWorkflow = await createWorkflow({ active: false }); + const activeWorkflow = await createActiveWorkflow(); + const inactiveWorkflow = await createWorkflow(); // Create old history versions for the active workflow const activeWorkflowVersions = await createManyWorkflowHistoryItems( @@ -130,6 +135,36 @@ describe('Workflow History Manager', () => { expect(await repo.count({ where: { versionId: In(otherVersionIds) } })).toBe(0); }); + test('should not prune current or active versions when they differ', async () => { + globalConfig.workflowHistory.pruneTime = 24; + + const workflow = await createActiveWorkflow(); + + // Create old history versions + const workflowVersions = await createManyWorkflowHistoryItems( + workflow.id, + 5, + DateTime.now().minus({ days: 2 }).toJSDate(), + ); + + // Set current version to one version and active version to a different version + workflow.versionId = workflowVersions[0].versionId; + workflow.activeVersionId = workflowVersions[1].versionId; + + const workflowRepo = Container.get(WorkflowRepository); + await workflowRepo.save(workflow); + + await manager.prune(); + + // Both current and active versions should still exist even though they are old + expect(await repo.count({ where: { versionId: workflow.versionId } })).toBe(1); + expect(await repo.count({ where: { versionId: workflow.activeVersionId } })).toBe(1); + + // Other old versions should be deleted + const otherVersionIds = workflowVersions.slice(2).map((i) => i.versionId); + expect(await repo.count({ where: { versionId: In(otherVersionIds) } })).toBe(0); + }); + const createWorkflowHistory = async (ageInDays = 2) => { const workflow = await createWorkflow(); const time = DateTime.now().minus({ days: ageInDays }).toJSDate(); @@ -139,7 +174,7 @@ describe('Workflow History Manager', () => { const pruneAndAssertCount = async (finalCount = 10, initialCount = 10) => { expect(await repo.count()).toBe(initialCount); - const deleteSpy = jest.spyOn(repo, 'deleteEarlierThanExceptCurrent'); + const deleteSpy = jest.spyOn(repo, 'deleteEarlierThanExceptCurrentAndActive'); await manager.prune(); if (initialCount === finalCount) { diff --git a/packages/cli/test/integration/workflows/workflow-index.test.ts b/packages/cli/test/integration/workflows/workflow-index.test.ts index 5c54ebb2e2e..5d7f67a7fad 100644 --- a/packages/cli/test/integration/workflows/workflow-index.test.ts +++ b/packages/cli/test/integration/workflows/workflow-index.test.ts @@ -65,6 +65,7 @@ describe('WorkflowIndexService Integration', () => { id: workflowId, name: 'Test Workflow', active: false, + activeVersionId: null, versionCounter: 1, versionId, nodes: [ diff --git a/packages/cli/test/integration/workflows/workflow.service.test.ts b/packages/cli/test/integration/workflows/workflow.service.test.ts index 93091f1de11..ae822fce4bd 100644 --- a/packages/cli/test/integration/workflows/workflow.service.test.ts +++ b/packages/cli/test/integration/workflows/workflow.service.test.ts @@ -1,4 +1,9 @@ -import { createWorkflowWithHistory, testDb, mockInstance } from '@n8n/backend-test-utils'; +import { + createWorkflowWithHistory, + createActiveWorkflow, + testDb, + mockInstance, +} from '@n8n/backend-test-utils'; import { SharedWorkflowRepository, type WorkflowEntity, WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; @@ -44,19 +49,24 @@ beforeAll(async () => { }); afterEach(async () => { - await testDb.truncate(['WorkflowEntity']); + await testDb.truncate(['WorkflowEntity', 'WorkflowHistory']); jest.restoreAllMocks(); }); describe('update()', () => { test('should remove and re-add to active workflows on `active: true` payload', async () => { const owner = await createOwner(); - const workflow = await createWorkflowWithHistory({ active: true }, owner); + const workflow = await createActiveWorkflow({}, owner); const removeSpy = jest.spyOn(activeWorkflowManager, 'remove'); const addSpy = jest.spyOn(activeWorkflowManager, 'add'); - await workflowService.update(owner, workflow, workflow.id); + const updateData = { + active: true, + versionId: workflow.versionId, + }; + + await workflowService.update(owner, updateData as WorkflowEntity, workflow.id); expect(removeSpy).toHaveBeenCalledTimes(1); const [removedWorkflowId] = removeSpy.mock.calls[0]; @@ -70,13 +80,17 @@ describe('update()', () => { test('should remove from active workflows on `active: false` payload', async () => { const owner = await createOwner(); - const workflow = await createWorkflowWithHistory({ active: true }, owner); + const workflow = await createActiveWorkflow({}, owner); const removeSpy = jest.spyOn(activeWorkflowManager, 'remove'); const addSpy = jest.spyOn(activeWorkflowManager, 'add'); - workflow.active = false; - await workflowService.update(owner, workflow, workflow.id); + const updateData = { + active: false, + versionId: workflow.versionId, + }; + + await workflowService.update(owner, updateData as WorkflowEntity, workflow.id); expect(removeSpy).toHaveBeenCalledTimes(1); const [removedWorkflowId] = removeSpy.mock.calls[0]; @@ -89,7 +103,7 @@ describe('update()', () => { const owner = await createOwner(); const workflow = await createWorkflowWithHistory({}, owner); - const updateData: Partial = { + const updateData = { nodes: [ { id: 'new-node', @@ -116,11 +130,11 @@ describe('update()', () => { test('should not save workflow history version when updating only active status', async () => { const owner = await createOwner(); - const workflow = await createWorkflowWithHistory({ active: false }, owner); + const workflow = await createWorkflowWithHistory({}, owner); const saveVersionSpy = jest.spyOn(workflowHistoryService, 'saveVersion'); - const updateData: Partial = { + const updateData = { active: true, versionId: workflow.versionId, }; @@ -132,13 +146,12 @@ describe('update()', () => { test('should save workflow history version with backfilled data when versionId changes', async () => { const owner = await createOwner(); - const workflow = await createWorkflowWithHistory({ active: false }, owner); + const workflow = await createWorkflowWithHistory({}, owner); const saveVersionSpy = jest.spyOn(workflowHistoryService, 'saveVersion'); const newVersionId = 'new-version-id-123'; - const updateData: Partial = { - active: true, + const updateData = { versionId: newVersionId, }; diff --git a/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts index a9971c3a78c..6b8e5bcc479 100644 --- a/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts @@ -1,8 +1,8 @@ import { createTeamProject, - createWorkflowWithTriggerAndHistory, testDb, mockInstance, + createActiveWorkflow, } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; @@ -39,7 +39,7 @@ describe('PUT /:workflowId/transfer', () => { // const destinationProject = await createTeamProject('Team Project', member); - const workflow = await createWorkflowWithTriggerAndHistory({ active: true }, member); + const workflow = await createActiveWorkflow({}, member); // // ACT diff --git a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts index dc846c67c77..abd98d05dd4 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts @@ -3,6 +3,7 @@ import { getPersonalProject, linkUserToProject, createWorkflow, + createActiveWorkflow, createWorkflowWithHistory, getWorkflowSharing, shareWorkflowWithProjects, @@ -1421,7 +1422,7 @@ describe('PATCH /workflows/:workflowId', () => { describe('activate workflow', () => { test('should activate workflow without changing version ID', async () => { - const workflow = await createWorkflow({}, owner); + const workflow = await createWorkflowWithHistory({}, owner); const payload = { versionId: workflow.versionId, active: true, @@ -1433,16 +1434,17 @@ describe('PATCH /workflows/:workflowId', () => { expect(activeWorkflowManager.add).toBeCalled(); const { - data: { id, versionId, active }, + data: { id, versionId, active, activeVersionId }, } = response.body; expect(id).toBe(workflow.id); expect(versionId).toBe(workflow.versionId); expect(active).toBe(true); + expect(activeVersionId).toBe(workflow.versionId); }); test('should deactivate workflow without changing version ID', async () => { - const workflow = await createWorkflowWithHistory({ active: true }, owner); + const workflow = await createActiveWorkflow({}, owner); const payload = { versionId: workflow.versionId, active: false, @@ -1455,12 +1457,13 @@ describe('PATCH /workflows/:workflowId', () => { expect(activeWorkflowManager.remove).toBeCalled(); const { - data: { id, versionId, active }, + data: { id, versionId, active, activeVersionId }, } = response.body; expect(id).toBe(workflow.id); expect(versionId).toBe(workflow.versionId); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); }); }); }); @@ -1636,7 +1639,7 @@ describe('PUT /:workflowId/transfer', () => { // const destinationProject = await createTeamProject('Team Project', member); - const workflow = await createWorkflowWithHistory({ active: true }, member); + const workflow = await createActiveWorkflow({}, member); // // ACT @@ -1664,10 +1667,7 @@ describe('PUT /:workflowId/transfer', () => { const folder = await createFolder(destinationProject, { name: 'Test Folder' }); - const workflow = await createWorkflowWithHistory( - { active: true, parentFolder: folder }, - member, - ); + const workflow = await createActiveWorkflow({ parentFolder: folder }, member); // // ACT @@ -1699,10 +1699,7 @@ describe('PUT /:workflowId/transfer', () => { const folder = await createFolder(destinationProject, { name: 'Test Folder' }); - const workflow = await createWorkflowWithHistory( - { active: true, parentFolder: folder }, - member, - ); + const workflow = await createActiveWorkflow({ parentFolder: folder }, member); // // ACT @@ -1742,10 +1739,7 @@ describe('PUT /:workflowId/transfer', () => { name: 'Another Test Folder', }); - const workflow = await createWorkflow( - { active: true, parentFolder: folderInDestinationProject }, - member, - ); + const workflow = await createWorkflow({ parentFolder: folderInDestinationProject }, member); // // ACT @@ -1766,7 +1760,7 @@ describe('PUT /:workflowId/transfer', () => { // const destinationProject = await createTeamProject('Team Project', member); - const workflow = await createWorkflowWithHistory({ active: true }, member); + const workflow = await createActiveWorkflow({}, member); activeWorkflowManager.add.mockRejectedValue(new WorkflowActivationError('Failed')); @@ -1795,7 +1789,8 @@ describe('PUT /:workflowId/transfer', () => { expect(activeWorkflowManager.add).toHaveBeenCalledWith(workflow.id, 'update'); const workflowFromDB = await workflowRepository.findOneByOrFail({ id: workflow.id }); - expect(workflowFromDB).toMatchObject({ active: false }); + expect(workflowFromDB.active).toBe(false); + expect(workflowFromDB.activeVersionId).toBeNull(); }); test('owner transfers workflow from project they are not part of, e.g. test global cred sharing scope', async () => { @@ -2136,7 +2131,7 @@ describe('PUT /:workflowId/transfer', () => { // const destinationProject = await createTeamProject('Team Project', member); - const workflow = await createWorkflowWithHistory({ active: true }, member); + const workflow = await createActiveWorkflow({}, member); activeWorkflowManager.add.mockRejectedValue(new ApplicationError('Oh no!')); diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index 9e25ba96644..87868a91728 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -3,7 +3,10 @@ import { getPersonalProject, linkUserToProject, createWorkflow, + createActiveWorkflow, + setActiveVersion, createWorkflowWithHistory, + createWorkflowWithTriggerAndHistory, shareWorkflowWithProjects, shareWorkflowWithUsers, randomCredentialPayload, @@ -62,10 +65,10 @@ let folderListMissingRole: Role; beforeEach(async () => { await testDb.truncate([ 'SharedWorkflow', - 'WorkflowHistory', 'ProjectRelation', 'Folder', 'WorkflowEntity', + 'WorkflowHistory', 'TagEntity', 'Project', 'User', @@ -131,6 +134,7 @@ describe('POST /workflows', () => { timezone: 'America/New_York', }, active: false, + activeVersionId: null, }; const response = await authMemberAgent.post('/workflows').send(payload); @@ -171,6 +175,7 @@ describe('POST /workflows', () => { staticData: null, settings: {}, active: false, + activeVersionId: null, uiContext: 'workflow_list', }; @@ -210,6 +215,7 @@ describe('POST /workflows', () => { timezone: 'America/New_York', }, active: false, + activeVersionId: null, }; const response = await authOwnerAgent.post('/workflows').send(payload); @@ -234,6 +240,52 @@ describe('POST /workflows', () => { expect(historyVersion!.nodes).toEqual(payload.nodes); }); + test('should create workflow as active when active: true is provided in POST body', async () => { + const payload = { + name: 'active workflow', + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + ], + connections: {}, + staticData: null, + settings: {}, + active: true, + }; + + const response = await authOwnerAgent.post('/workflows').send(payload); + + expect(response.statusCode).toBe(200); + + const { + data: { id, versionId, activeVersionId, active }, + } = response.body; + + expect(id).toBeDefined(); + expect(versionId).toBeDefined(); + expect(activeVersionId).toBe(versionId); // Should be set to current version + expect(active).toBe(true); + + // Verify in database + const workflow = await Container.get(WorkflowRepository).findOneBy({ id }); + expect(workflow?.activeVersionId).toBe(versionId); + + // Verify history was created + const historyVersion = await Container.get(WorkflowHistoryRepository).findOne({ + where: { + workflowId: id, + versionId, + }, + }); + expect(historyVersion).not.toBeNull(); + }); + test('create workflow in personal project by default', async () => { // // ARRANGE @@ -261,6 +313,7 @@ describe('POST /workflows', () => { }); expect(response.body.data).toMatchObject({ active: false, + activeVersionId: null, id: expect.any(String), name: workflow.name, sharedWithProjects: [], @@ -311,6 +364,7 @@ describe('POST /workflows', () => { }); expect(response.body.data).toMatchObject({ active: false, + activeVersionId: null, id: expect.any(String), name: workflow.name, sharedWithProjects: [], @@ -407,6 +461,7 @@ describe('POST /workflows', () => { expect(response.body.data).toMatchObject({ active: false, + activeVersionId: null, id: expect.any(String), name: workflow.name, sharedWithProjects: [], @@ -445,6 +500,7 @@ describe('POST /workflows', () => { expect(response.body.data).toMatchObject({ active: false, + activeVersionId: null, id: expect.any(String), name: workflow.name, sharedWithProjects: [], @@ -480,6 +536,7 @@ describe('POST /workflows', () => { expect(response.body.data).toMatchObject({ active: false, + activeVersionId: null, id: expect.any(String), name: workflow.name, sharedWithProjects: [], @@ -613,7 +670,8 @@ describe('GET /workflows', () => { objectContaining({ id: any(String), name: 'First', - active: any(Boolean), + active: false, + activeVersionId: null, createdAt: any(String), updatedAt: any(String), tags: [{ id: any(String), name: 'A' }], @@ -629,7 +687,8 @@ describe('GET /workflows', () => { objectContaining({ id: any(String), name: 'Second', - active: any(Boolean), + active: false, + activeVersionId: null, createdAt: any(String), updatedAt: any(String), tags: [], @@ -813,8 +872,8 @@ describe('GET /workflows', () => { }); test('should filter workflows by field: active', async () => { - await createWorkflow({ active: true }, owner); - await createWorkflow({ active: false }, owner); + await createActiveWorkflow({}, owner); + await createWorkflow({}, owner); const response = await authOwnerAgent .get('/workflows') @@ -823,7 +882,7 @@ describe('GET /workflows', () => { expect(response.body).toEqual({ count: 1, - data: [objectContaining({ active: true })], + data: [objectContaining({ active: true, activeVersionId: expect.any(String) })], }); }); @@ -1121,8 +1180,8 @@ describe('GET /workflows', () => { }); test('should select workflow field: active', async () => { - await createWorkflow({ active: true }, owner); - await createWorkflow({ active: false }, owner); + await createActiveWorkflow({}, owner); + await createWorkflow({}, owner); const response = await authOwnerAgent .get('/workflows') @@ -1138,6 +1197,24 @@ describe('GET /workflows', () => { }); }); + test('should select workflow field: activeVersionId', async () => { + const activeWorkflow = await createActiveWorkflow({}, owner); + await createWorkflow({}, owner); + + const response = await authOwnerAgent + .get('/workflows') + .query('select=["activeVersionId"]') + .expect(200); + + expect(response.body).toEqual({ + count: 2, + data: arrayContaining([ + { id: any(String), activeVersionId: activeWorkflow.versionId }, + { id: any(String), activeVersionId: null }, + ]), + }); + }); + test('should select workflow field: tags', async () => { const firstWorkflow = await createWorkflow({ name: 'First' }, owner); const secondWorkflow = await createWorkflow({ name: 'Second' }, owner); @@ -1477,7 +1554,8 @@ describe('GET /workflows?onlySharedWithMe=true', () => { objectContaining({ id: any(String), name: 'Third', - active: any(Boolean), + active: false, + activeVersionId: null, createdAt: any(String), updatedAt: any(String), versionId: any(String), @@ -1549,7 +1627,8 @@ describe('GET /workflows?includeFolders=true', () => { resource: 'workflow', id: any(String), name: 'First', - active: any(Boolean), + active: false, + activeVersionId: null, createdAt: any(String), updatedAt: any(String), tags: [{ id: any(String), name: 'A' }], @@ -1565,7 +1644,7 @@ describe('GET /workflows?includeFolders=true', () => { objectContaining({ id: any(String), name: 'Second', - active: any(Boolean), + activeVersionId: null, createdAt: any(String), updatedAt: any(String), tags: [], @@ -1846,8 +1925,8 @@ describe('GET /workflows?includeFolders=true', () => { }); test('should filter workflows and folders by field: active', async () => { - const workflow1 = await createWorkflow({ active: true }, owner); - await createWorkflow({ active: false }, owner); + const workflow1 = await createActiveWorkflow({}, owner); + await createWorkflow({}, owner); const response = await authOwnerAgent .get('/workflows') @@ -1856,7 +1935,9 @@ describe('GET /workflows?includeFolders=true', () => { expect(response.body).toEqual({ count: 1, - data: [objectContaining({ id: workflow1.id, active: true })], + data: [ + objectContaining({ id: workflow1.id, active: true, versionId: workflow1.versionId }), + ], }); }); @@ -2408,16 +2489,17 @@ describe('PATCH /workflows/:workflowId', () => { expect(activeWorkflowManagerLike.add).toBeCalled(); const { - data: { id, versionId, active }, + data: { id, versionId, active, activeVersionId }, } = response.body; expect(id).toBe(workflow.id); expect(versionId).toBe(workflow.versionId); expect(active).toBe(true); + expect(activeVersionId).toBe(workflow.versionId); }); test('should deactivate workflow without changing version ID', async () => { - const workflow = await createWorkflowWithHistory({ active: true }, owner); + const workflow = await createActiveWorkflow({}, owner); const payload = { versionId: workflow.versionId, active: false, @@ -2430,12 +2512,129 @@ describe('PATCH /workflows/:workflowId', () => { expect(activeWorkflowManagerLike.remove).toBeCalled(); const { - data: { id, versionId, active }, + data: { id, versionId, active, activeVersionId }, } = response.body; expect(id).toBe(workflow.id); expect(versionId).toBe(workflow.versionId); expect(active).toBe(false); + expect(activeVersionId).toBeNull(); + }); + + test('should set activeVersionId when activating via PATCH', async () => { + const workflow = await createWorkflowWithTriggerAndHistory({}, owner); + + const payload = { + versionId: workflow.versionId, + active: true, + }; + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + + expect(response.statusCode).toBe(200); + expect(activeWorkflowManagerLike.add).toBeCalled(); + + const { + data: { id, activeVersionId }, + } = response.body; + + expect(id).toBe(workflow.id); + expect(activeVersionId).toBe(workflow.versionId); + + // Verify activeVersion is set + const updatedWorkflow = await Container.get(WorkflowRepository).findOne({ + where: { id: workflow.id }, + relations: ['activeVersion'], + }); + + expect(updatedWorkflow?.activeVersionId).toBe(workflow.versionId); + expect(updatedWorkflow?.activeVersion).not.toBeNull(); + expect(updatedWorkflow?.activeVersion?.versionId).toBe(workflow.versionId); + expect(updatedWorkflow?.activeVersion?.nodes).toEqual(workflow.nodes); + expect(updatedWorkflow?.activeVersion?.connections).toEqual(workflow.connections); + }); + + test('should clear activeVersionId when deactivating via PATCH', async () => { + const workflow = await createActiveWorkflow({}, owner); + + await setActiveVersion(workflow.id, workflow.versionId); + + const payload = { + versionId: workflow.versionId, + active: false, + }; + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + + expect(response.statusCode).toBe(200); + expect(activeWorkflowManagerLike.remove).toBeCalled(); + + const { + data: { id, activeVersionId }, + } = response.body; + + expect(id).toBe(workflow.id); + expect(activeVersionId).toBeNull(); + + // Verify activeVersion is cleared + const updatedWorkflow = await Container.get(WorkflowRepository).findOne({ + where: { id: workflow.id }, + }); + + expect(updatedWorkflow?.activeVersionId).toBeNull(); + }); + + test('should update activeVersionId when updating an active workflow', async () => { + const workflow = await createActiveWorkflow({}, owner); + + await setActiveVersion(workflow.id, workflow.versionId); + + // Verify initial state + const initialWorkflow = await Container.get(WorkflowRepository).findOne({ + where: { id: workflow.id }, + relations: ['activeVersion'], + }); + expect(initialWorkflow?.activeVersion?.versionId).toBe(workflow.versionId); + + // Update workflow nodes + const updatedNodes: INode[] = [ + { + id: 'uuid-updated', + parameters: { triggerTimes: { item: [{ mode: 'everyHour' }] } }, + name: 'Cron Updated', + type: 'n8n-nodes-base.cron', + typeVersion: 1, + position: [500, 400], + }, + ]; + + const payload = { + versionId: workflow.versionId, + nodes: updatedNodes, + connections: {}, + }; + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + + expect(response.statusCode).toBe(200); + + const { + data: { id, versionId: newVersionId }, + } = response.body; + + expect(id).toBe(workflow.id); + expect(newVersionId).not.toBe(workflow.versionId); + + // Verify activeVersion points to the new version + const updatedWorkflow = await Container.get(WorkflowRepository).findOne({ + where: { id: workflow.id }, + relations: ['activeVersion'], + }); + + expect(updatedWorkflow?.active).toBe(true); + expect(updatedWorkflow?.activeVersionId).not.toBeNull(); + expect(updatedWorkflow?.activeVersion?.versionId).toBe(newVersionId); + expect(updatedWorkflow?.activeVersion?.nodes).toEqual(updatedNodes); }); test('should update workflow meta', async () => { @@ -2559,11 +2758,12 @@ describe('POST /workflows/:workflowId/archive', () => { .expect(200); const { - data: { isArchived, versionId }, + data: { isArchived, versionId, active }, } = response.body; expect(isArchived).toBe(true); expect(versionId).not.toBe(workflow.versionId); + expect(active).toBe(false); const updatedWorkflow = await Container.get(WorkflowRepository).findById(workflow.id); expect(updatedWorkflow).not.toBeNull(); @@ -2571,17 +2771,18 @@ describe('POST /workflows/:workflowId/archive', () => { }); test('should deactivate active workflow on archive', async () => { - const workflow = await createWorkflow({ active: true }, owner); + const workflow = await createActiveWorkflow({}, owner); const response = await authOwnerAgent .post(`/workflows/${workflow.id}/archive`) .send() .expect(200); const { - data: { isArchived, versionId, active }, + data: { isArchived, versionId, activeVersionId, active }, } = response.body; expect(isArchived).toBe(true); + expect(activeVersionId).toBeNull(); expect(active).toBe(false); expect(versionId).not.toBe(workflow.versionId); expect(activeWorkflowManagerLike.remove).toBeCalledWith(workflow.id); @@ -2824,6 +3025,7 @@ describe('POST /workflows/:workflowId/unarchive', () => { .expect(200); expect(activateResponse.body.data.active).toBe(true); + expect(activateResponse.body.data.activeVersionId).toBeDefined(); }); }); diff --git a/packages/core/src/binary-data/__tests__/binary-data-service.test.ts b/packages/core/src/binary-data/__tests__/binary-data-service.test.ts index f5ff6cf4faa..0df6a345c22 100644 --- a/packages/core/src/binary-data/__tests__/binary-data-service.test.ts +++ b/packages/core/src/binary-data/__tests__/binary-data-service.test.ts @@ -2,6 +2,8 @@ import { mock } from 'jest-mock-extended'; import { sign, JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken'; import type { IBinaryData } from 'n8n-workflow'; +import type { ErrorReporter } from '@/errors'; + import type { BinaryDataConfig } from '../binary-data.config'; import { BinaryDataService } from '../binary-data.service'; @@ -11,6 +13,7 @@ jest.useFakeTimers({ now }); describe('BinaryDataService', () => { const signingSecret = 'test-signing-secret'; const config = mock({ signingSecret }); + const errorReporter = mock(); const binaryData = mock({ id: 'filesystem:id_123' }); const validToken = sign({ id: binaryData.id }, signingSecret, { expiresIn: '1 day' }); @@ -19,7 +22,7 @@ describe('BinaryDataService', () => { jest.resetAllMocks(); config.signingSecret = signingSecret; - service = new BinaryDataService(config); + service = new BinaryDataService(config, errorReporter); }); describe('createSignedToken', () => { diff --git a/packages/core/src/binary-data/__tests__/file-system.manager.test.ts b/packages/core/src/binary-data/__tests__/file-system.manager.test.ts index 8f72dac38fb..2735555d35e 100644 --- a/packages/core/src/binary-data/__tests__/file-system.manager.test.ts +++ b/packages/core/src/binary-data/__tests__/file-system.manager.test.ts @@ -1,3 +1,4 @@ +import { mock } from 'jest-mock-extended'; import fs from 'node:fs'; import fsp from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -5,14 +6,18 @@ import path from 'node:path'; import { Readable } from 'node:stream'; import { FileSystemManager } from '@/binary-data/file-system.manager'; +import type { ErrorReporter } from '@/errors'; import { toFileId, toStream } from '@test/utils'; +import type { BinaryData } from '../types'; + jest.mock('fs'); jest.mock('fs/promises'); const storagePath = tmpdir(); +const errorReporter = mock(); -const fsManager = new FileSystemManager(storagePath); +const fsManager = new FileSystemManager(storagePath, errorReporter); const toFullFilePath = (fileId: string) => path.join(storagePath, fileId); @@ -37,7 +42,11 @@ describe('store()', () => { it('should store a buffer', async () => { const metadata = { mimeType: 'text/plain' }; - const result = await fsManager.store(workflowId, executionId, mockBuffer, metadata); + const result = await fsManager.store( + { type: 'execution', workflowId, executionId }, + mockBuffer, + metadata, + ); expect(result.fileSize).toBe(mockBuffer.length); }); @@ -104,7 +113,10 @@ describe('copyByFileId()', () => { // @ts-expect-error - private method jest.spyOn(fsManager, 'toFileId').mockReturnValue(otherFileId); - const targetFileId = await fsManager.copyByFileId(workflowId, executionId, fileId); + const targetFileId = await fsManager.copyByFileId( + { type: 'execution', workflowId, executionId }, + fileId, + ); const sourcePath = toFullFilePath(fileId); const targetPath = toFullFilePath(targetFileId); @@ -133,8 +145,7 @@ describe('copyByFilePath()', () => { fsp.writeFile = jest.fn().mockResolvedValue(undefined); const result = await fsManager.copyByFilePath( - workflowId, - executionId, + { type: 'execution', workflowId, executionId }, sourceFilePath, metadata, ); @@ -156,9 +167,9 @@ describe('deleteMany()', () => { }; it('should delete many files by workflow ID and execution ID', async () => { - const ids = [ - { workflowId, executionId }, - { workflowId: otherWorkflowId, executionId: otherExecutionId }, + const ids: BinaryData.FileLocation[] = [ + { type: 'execution', workflowId, executionId }, + { type: 'execution', workflowId: otherWorkflowId, executionId: otherExecutionId }, ]; fsp.rm = jest.fn().mockResolvedValue(undefined); @@ -181,7 +192,9 @@ describe('deleteMany()', () => { }); it('should suppress error on non-existing filepath', async () => { - const ids = [{ workflowId: 'does-not-exist', executionId: 'does-not-exist' }]; + const ids: BinaryData.FileLocation[] = [ + { type: 'execution', workflowId: 'does-not-exist', executionId: 'does-not-exist' }, + ]; fsp.rm = jest.fn().mockResolvedValue(undefined); diff --git a/packages/core/src/binary-data/__tests__/object-store.manager.test.ts b/packages/core/src/binary-data/__tests__/object-store.manager.test.ts index 09696f7d374..5a47e724af0 100644 --- a/packages/core/src/binary-data/__tests__/object-store.manager.test.ts +++ b/packages/core/src/binary-data/__tests__/object-store.manager.test.ts @@ -34,7 +34,11 @@ describe('store()', () => { it('should store a buffer', async () => { const metadata = { mimeType: 'text/plain' }; - const result = await objectStoreManager.store(workflowId, executionId, mockBuffer, metadata); + const result = await objectStoreManager.store( + { type: 'execution', workflowId, executionId }, + mockBuffer, + metadata, + ); expect(result.fileId.startsWith(prefix)).toBe(true); expect(result.fileSize).toBe(mockBuffer.length); @@ -94,7 +98,10 @@ describe('getMetadata()', () => { describe('copyByFileId()', () => { it('should copy by file ID and return the file ID', async () => { - const targetFileId = await objectStoreManager.copyByFileId(workflowId, executionId, fileId); + const targetFileId = await objectStoreManager.copyByFileId( + { type: 'execution', workflowId, executionId }, + fileId, + ); expect(targetFileId.startsWith(prefix)).toBe(true); expect(objectStoreService.get).toHaveBeenCalledWith(fileId, { mode: 'buffer' }); @@ -109,8 +116,7 @@ describe('copyByFilePath()', () => { fs.readFile = jest.fn().mockResolvedValue(mockBuffer); const result = await objectStoreManager.copyByFilePath( - workflowId, - executionId, + { type: 'execution', workflowId, executionId }, sourceFilePath, metadata, ); diff --git a/packages/core/src/binary-data/binary-data.service.ts b/packages/core/src/binary-data/binary-data.service.ts index 6085e27f571..229b8a65b04 100644 --- a/packages/core/src/binary-data/binary-data.service.ts +++ b/packages/core/src/binary-data/binary-data.service.ts @@ -7,6 +7,8 @@ import { readFile, stat } from 'node:fs/promises'; import prettyBytes from 'pretty-bytes'; import type { Readable } from 'stream'; +import { ErrorReporter } from '@/errors'; + import { BinaryDataConfig } from './binary-data.config'; import type { BinaryData } from './types'; import { areConfigModes, binaryToBuffer } from './utils'; @@ -19,7 +21,10 @@ export class BinaryDataService { private managers: Record = {}; - constructor(private readonly config: BinaryDataConfig) {} + constructor( + private readonly config: BinaryDataConfig, + private readonly errorReporter: ErrorReporter, + ) {} async init() { const { config } = this; @@ -30,7 +35,7 @@ export class BinaryDataService { if (config.availableModes.includes('filesystem')) { const { FileSystemManager } = await import('./file-system.manager'); - this.managers.filesystem = new FileSystemManager(config.localStoragePath); + this.managers.filesystem = new FileSystemManager(config.localStoragePath, this.errorReporter); this.managers['filesystem-v2'] = this.managers.filesystem; await this.managers.filesystem.init(); @@ -66,8 +71,7 @@ export class BinaryDataService { } async copyBinaryFile( - workflowId: string, - executionId: string, + location: BinaryData.FileLocation, binaryData: IBinaryData, filePath: string, ) { @@ -86,12 +90,7 @@ export class BinaryDataService { mimeType: binaryData.mimeType, }; - const { fileId, fileSize } = await manager.copyByFilePath( - workflowId, - executionId, - filePath, - metadata, - ); + const { fileId, fileSize } = await manager.copyByFilePath(location, filePath, metadata); binaryData.id = this.createBinaryDataId(fileId); binaryData.fileSize = prettyBytes(fileSize); @@ -101,8 +100,7 @@ export class BinaryDataService { } async store( - workflowId: string, - executionId: string, + location: BinaryData.FileLocation, bufferOrStream: Buffer | Readable, binaryData: IBinaryData, ) { @@ -121,12 +119,7 @@ export class BinaryDataService { mimeType: binaryData.mimeType, }; - const { fileId, fileSize } = await manager.store( - workflowId, - executionId, - bufferOrStream, - metadata, - ); + const { fileId, fileSize } = await manager.store(location, bufferOrStream, metadata); binaryData.id = this.createBinaryDataId(fileId); binaryData.fileSize = prettyBytes(fileSize); @@ -163,17 +156,28 @@ export class BinaryDataService { return await this.getManager(mode).getMetadata(fileId); } - async deleteMany(ids: BinaryData.IdsForDeletion) { + async deleteMany(locations: BinaryData.FileLocation[]) { const manager = this.managers[this.mode]; if (!manager) return; - if (manager.deleteMany) await manager.deleteMany(ids); + if (manager.deleteMany) await manager.deleteMany(locations); + } + + async deleteManyByBinaryDataId(ids: string[]) { + const manager = this.managers[this.mode]; + + const fileIds = ids.flatMap((attachmentId) => { + const [, fileId] = attachmentId.split(':'); // remove mode + + return fileId ? [fileId] : []; + }); + + await manager.deleteManyByFileId?.(fileIds); } async duplicateBinaryData( - workflowId: string, - executionId: string, + location: BinaryData.FileLocation, inputData: Array, ) { if (inputData && this.managers[this.mode]) { @@ -183,11 +187,7 @@ export class BinaryDataService { return await Promise.all( executionDataArray.map(async (executionData) => { if (executionData.binary) { - return await this.duplicateBinaryDataInExecData( - workflowId, - executionId, - executionData, - ); + return await this.duplicateBinaryDataInExecData(location, executionData); } return executionData; @@ -222,8 +222,7 @@ export class BinaryDataService { } private async duplicateBinaryDataInExecData( - workflowId: string, - executionId: string, + location: BinaryData.FileLocation, executionData: INodeExecutionData, ) { const manager = this.managers[this.mode]; @@ -242,7 +241,7 @@ export class BinaryDataService { const [_mode, fileId] = binaryDataId.split(':'); - return await manager?.copyByFileId(workflowId, executionId, fileId).then((newFileId) => ({ + return await manager?.copyByFileId(location, fileId).then((newFileId) => ({ newId: this.createBinaryDataId(newFileId), key, })); diff --git a/packages/core/src/binary-data/file-system.manager.ts b/packages/core/src/binary-data/file-system.manager.ts index a951e98cd42..6f6bde136bd 100644 --- a/packages/core/src/binary-data/file-system.manager.ts +++ b/packages/core/src/binary-data/file-system.manager.ts @@ -1,32 +1,40 @@ -import { jsonParse } from 'n8n-workflow'; +import { jsonParse, UnexpectedError } from 'n8n-workflow'; import { createReadStream } from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; import type { Readable } from 'stream'; import { v4 as uuid } from 'uuid'; +import type { ErrorReporter } from '@/errors'; + import type { BinaryData } from './types'; -import { assertDir, doesNotExist } from './utils'; +import { assertDir, doesNotExist, FileLocation } from './utils'; import { DisallowedFilepathError } from '../errors/disallowed-filepath.error'; import { FileNotFoundError } from '../errors/file-not-found.error'; const EXECUTION_ID_EXTRACTOR = /^(\w+)(?:[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})$/; +const EXECUTION_PATH_MATCHER = /^workflows\/([^/]+)\/executions\/([^/]+)\//; + +const CHAT_HUB_ATTACHMENT_PATH_MATCHER = /^chat-hub\/sessions\/([^/]+)\/messages\/([^/]+)\//; + export class FileSystemManager implements BinaryData.Manager { - constructor(private storagePath: string) {} + constructor( + private storagePath: string, + private readonly errorReporter: ErrorReporter, + ) {} async init() { await assertDir(this.storagePath); } async store( - workflowId: string, - executionId: string, + location: BinaryData.FileLocation, bufferOrStream: Buffer | Readable, { mimeType, fileName }: BinaryData.PreWriteMetadata, ) { - const fileId = this.toFileId(workflowId, executionId); + const fileId = this.toFileId(location); const filePath = this.resolvePath(fileId); await assertDir(path.dirname(filePath)); @@ -70,12 +78,14 @@ export class FileSystemManager implements BinaryData.Manager { return await jsonParse(await fs.readFile(filePath, { encoding: 'utf-8' })); } - async deleteMany(ids: BinaryData.IdsForDeletion) { - if (ids.length === 0) return; + async deleteMany(locations: BinaryData.FileLocation[]) { + if (locations.length === 0) return; // binary files stored in single dir - `filesystem` - const executionIds = ids.map((o) => o.executionId); + const executionIds = locations.flatMap((location) => + location.type === 'execution' ? [location.executionId] : [], + ); const set = new Set(executionIds); const fileNames = await fs.readdir(this.storagePath); @@ -92,8 +102,8 @@ export class FileSystemManager implements BinaryData.Manager { // binary files stored in nested dirs - `filesystem-v2` - const binaryDataDirs = ids.map(({ workflowId, executionId }) => - this.resolvePath(`workflows/${workflowId}/executions/${executionId}`), + const binaryDataDirs = locations.map((location) => + this.resolvePath(this.toRelativePath(location)), ); await Promise.all( @@ -104,12 +114,11 @@ export class FileSystemManager implements BinaryData.Manager { } async copyByFilePath( - workflowId: string, - executionId: string, + targetLocation: BinaryData.FileLocation, sourcePath: string, { mimeType, fileName }: BinaryData.PreWriteMetadata, ) { - const targetFileId = this.toFileId(workflowId, executionId); + const targetFileId = this.toFileId(targetLocation); const targetPath = this.resolvePath(targetFileId); await assertDir(path.dirname(targetPath)); @@ -123,8 +132,8 @@ export class FileSystemManager implements BinaryData.Manager { return { fileId: targetFileId, fileSize }; } - async copyByFileId(workflowId: string, executionId: string, sourceFileId: string) { - const targetFileId = this.toFileId(workflowId, executionId); + async copyByFileId(targetLocation: BinaryData.FileLocation, sourceFileId: string) { + const targetFileId = this.toFileId(targetLocation); const sourcePath = this.resolvePath(sourceFileId); const targetPath = this.resolvePath(targetFileId); const sourceMetadata = await this.getMetadata(sourceFileId); @@ -155,6 +164,21 @@ export class FileSystemManager implements BinaryData.Manager { await fs.rm(tempDir, { recursive: true }); } + async deleteManyByFileId(ids: string[]): Promise { + const parsedIds = ids.flatMap((id) => { + try { + const parsed = this.parseFileId(id); + + return [parsed]; + } catch (e) { + this.errorReporter.warn(`Could not parse file ID ${id}. Skip deletion`); + return []; + } + }); + + await this.deleteMany(parsedIds); + } + // ---------------------------------- // private methods // ---------------------------------- @@ -165,10 +189,35 @@ export class FileSystemManager implements BinaryData.Manager { * The legacy ID format `{executionId}{uuid}` for `filesystem` mode is * no longer used on write, only when reading old stored execution data. */ - private toFileId(workflowId: string, executionId: string) { - if (!executionId) executionId = 'temp'; // missing only in edge case, see PR #7244 + private toFileId(location: BinaryData.FileLocation) { + return `${this.toRelativePath(location)}/binary_data/${uuid()}`; + } - return `workflows/${workflowId}/executions/${executionId}/binary_data/${uuid()}`; + private toRelativePath(location: BinaryData.FileLocation) { + switch (location.type) { + case 'execution': { + const executionId = location.executionId || 'temp'; // missing only in edge case, see PR #7244 + return `workflows/${location.workflowId}/executions/${executionId}`; + } + case 'chat-hub-message-attachment': + return `chat-hub/sessions/${location.sessionId}/messages/${location.messageId}`; + } + } + + private parseFileId(fileId: string): BinaryData.FileLocation { + const executionMatch = fileId.match(EXECUTION_PATH_MATCHER); + + if (executionMatch) { + return FileLocation.ofExecution(executionMatch[1], executionMatch[2]); + } + + const chatHubMatch = fileId.match(CHAT_HUB_ATTACHMENT_PATH_MATCHER); + + if (chatHubMatch) { + return FileLocation.ofChatHubMessageAttachment(chatHubMatch[1], chatHubMatch[2]); + } + + throw new UnexpectedError(`File ID ${fileId} has invalid format.`); } private resolvePath(...args: string[]) { diff --git a/packages/core/src/binary-data/index.ts b/packages/core/src/binary-data/index.ts index 975988700ae..9a0f0f46b41 100644 --- a/packages/core/src/binary-data/index.ts +++ b/packages/core/src/binary-data/index.ts @@ -2,4 +2,4 @@ export * from './binary-data.service'; export { BinaryDataConfig } from './binary-data.config'; export type * from './types'; export { ObjectStoreService } from './object-store/object-store.service.ee'; -export { isStoredMode as isValidNonDefaultMode } from './utils'; +export { isStoredMode as isValidNonDefaultMode, FileLocation } from './utils'; diff --git a/packages/core/src/binary-data/object-store.manager.ts b/packages/core/src/binary-data/object-store.manager.ts index cc0fa564cef..79156a35a33 100644 --- a/packages/core/src/binary-data/object-store.manager.ts +++ b/packages/core/src/binary-data/object-store.manager.ts @@ -16,12 +16,11 @@ export class ObjectStoreManager implements BinaryData.Manager { } async store( - workflowId: string, - executionId: string, + location: BinaryData.FileLocation, bufferOrStream: Buffer | Readable, metadata: BinaryData.PreWriteMetadata, ) { - const fileId = this.toFileId(workflowId, executionId); + const fileId = this.toFileId(location); const buffer = await binaryToBuffer(bufferOrStream); await this.objectStoreService.put(fileId, buffer, metadata); @@ -56,8 +55,8 @@ export class ObjectStoreManager implements BinaryData.Manager { return metadata; } - async copyByFileId(workflowId: string, executionId: string, sourceFileId: string) { - const targetFileId = this.toFileId(workflowId, executionId); + async copyByFileId(targetLocation: BinaryData.FileLocation, sourceFileId: string) { + const targetFileId = this.toFileId(targetLocation); const sourceFile = await this.objectStoreService.get(sourceFileId, { mode: 'buffer' }); @@ -70,12 +69,11 @@ export class ObjectStoreManager implements BinaryData.Manager { * Copy to object store the temp file written by nodes like Webhook, FTP, and SSH. */ async copyByFilePath( - workflowId: string, - executionId: string, + targetLocation: BinaryData.FileLocation, sourcePath: string, metadata: BinaryData.PreWriteMetadata, ) { - const targetFileId = this.toFileId(workflowId, executionId); + const targetFileId = this.toFileId(targetLocation); const sourceFile = await fs.readFile(sourcePath); await this.objectStoreService.put(targetFileId, sourceFile, metadata); @@ -95,9 +93,14 @@ export class ObjectStoreManager implements BinaryData.Manager { // private methods // ---------------------------------- - private toFileId(workflowId: string, executionId: string) { - if (!executionId) executionId = 'temp'; // missing only in edge case, see PR #7244 - - return `workflows/${workflowId}/executions/${executionId}/binary_data/${uuid()}`; + private toFileId(location: BinaryData.FileLocation) { + switch (location.type) { + case 'execution': { + const executionId = location.executionId || 'temp'; // missing only in edge case, see PR #7244 + return `workflows/${location.workflowId}/executions/${executionId}/binary_data/${uuid()}`; + } + case 'chat-hub-message-attachment': + return `chat-hub/sessions/${location.sessionId}/messages/${location.messageId}/binary_data/${uuid()}`; + } } } diff --git a/packages/core/src/binary-data/types.ts b/packages/core/src/binary-data/types.ts index 9ee54792773..88657dc5d55 100644 --- a/packages/core/src/binary-data/types.ts +++ b/packages/core/src/binary-data/types.ts @@ -32,14 +32,15 @@ export namespace BinaryData { export type PreWriteMetadata = Omit; - export type IdsForDeletion = Array<{ workflowId: string; executionId: string }>; + export type FileLocation = + | { type: 'execution'; workflowId: string; executionId: string } + | { type: 'chat-hub-message-attachment'; sessionId: string; messageId: string }; export interface Manager { init(): Promise; store( - workflowId: string, - executionId: string, + location: FileLocation, bufferOrStream: Buffer | Readable, metadata: PreWriteMetadata, ): Promise; @@ -52,12 +53,12 @@ export namespace BinaryData { /** * Present for `FileSystem`, absent for `ObjectStore` (delegated to S3 lifecycle config) */ - deleteMany?(ids: IdsForDeletion): Promise; + deleteMany?(locations: FileLocation[]): Promise; + deleteManyByFileId?(ids: string[]): Promise; - copyByFileId(workflowId: string, executionId: string, sourceFileId: string): Promise; + copyByFileId(targetLocation: FileLocation, sourceFileId: string): Promise; copyByFilePath( - workflowId: string, - executionId: string, + targetLocation: FileLocation, sourcePath: string, metadata: PreWriteMetadata, ): Promise; diff --git a/packages/core/src/binary-data/utils.ts b/packages/core/src/binary-data/utils.ts index 13f46fef617..a7852e1fca1 100644 --- a/packages/core/src/binary-data/utils.ts +++ b/packages/core/src/binary-data/utils.ts @@ -52,3 +52,16 @@ export async function binaryToBuffer(body: Buffer | Readable) { if (Buffer.isBuffer(body)) return body; return await streamToBuffer(body); } + +export const FileLocation = { + ofExecution: (workflowId: string, executionId: string): BinaryData.FileLocation => ({ + type: 'execution', + workflowId, + executionId, + }), + ofChatHubMessageAttachment: (sessionId: string, messageId: string): BinaryData.FileLocation => ({ + type: 'chat-hub-message-attachment', + sessionId, + messageId, + }), +}; diff --git a/packages/core/src/execution-engine/node-execution-context/__tests__/shared-tests.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/shared-tests.ts index e7e2ab439d6..3692d3fd257 100644 --- a/packages/core/src/execution-engine/node-execution-context/__tests__/shared-tests.ts +++ b/packages/core/src/execution-engine/node-execution-context/__tests__/shared-tests.ts @@ -286,8 +286,7 @@ export const describeCommonTests = ( expect(result.data).toEqual(data); expect(binaryDataService.duplicateBinaryData).toHaveBeenCalledWith( - workflow.id, - additionalData.executionId, + { type: 'execution', workflowId: workflow.id, executionId: additionalData.executionId }, executeWorkflowData.data, ); }); diff --git a/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts b/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts index 063e3e8f572..354a798145e 100644 --- a/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts @@ -34,6 +34,7 @@ import { } from 'n8n-workflow'; import { BinaryDataService } from '@/binary-data/binary-data.service'; +import { FileLocation } from '@/binary-data/utils'; import { NodeExecutionContext } from './node-execution-context'; @@ -155,8 +156,7 @@ export class BaseExecuteContext extends NodeExecutionContext { } const data = await this.binaryDataService.duplicateBinaryData( - this.workflow.id, - this.additionalData.executionId!, + FileLocation.ofExecution(this.workflow.id, this.additionalData.executionId!), result.data, ); return { ...result, data }; diff --git a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/binary-helper-functions.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/binary-helper-functions.test.ts index 4e24ebd4735..98420d4c6d6 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/binary-helper-functions.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/binary-helper-functions.test.ts @@ -14,6 +14,7 @@ import { Readable } from 'stream'; import type { BinaryDataConfig } from '@/binary-data'; import { BinaryDataService } from '@/binary-data/binary-data.service'; +import type { ErrorReporter } from '@/errors'; import { assertBinaryData, @@ -44,11 +45,12 @@ describe('test binary data helper methods', () => { availableModes: ['default', 'filesystem'], localStoragePath: temporaryDir, }); + const errorReporter = mock(); let binaryDataService: BinaryDataService; beforeEach(() => { jest.resetAllMocks(); - binaryDataService = new BinaryDataService(binaryDataConfig); + binaryDataService = new BinaryDataService(binaryDataConfig, errorReporter); Container.set(BinaryDataService, binaryDataService); }); @@ -592,8 +594,7 @@ describe('copyBinaryFile', () => { expect(result.fileName).toBe(fileName); expect(binaryDataService.copyBinaryFile).toHaveBeenCalledWith( - workflowId, - executionId, + { type: 'execution', workflowId, executionId }, { ...binaryData, fileExtension: 'txt', @@ -614,8 +615,7 @@ describe('copyBinaryFile', () => { expect(result.fileName).toBe(fileName); expect(binaryDataService.copyBinaryFile).toHaveBeenCalledWith( - workflowId, - executionId, + { type: 'execution', workflowId, executionId }, { ...binaryData, fileExtension: 'bin', @@ -635,7 +635,7 @@ describe('prepareBinaryData', () => { jest.resetAllMocks(); Container.set(BinaryDataService, binaryDataService); - binaryDataService.store.mockImplementation(async (_w, _e, _b, binaryData) => binaryData); + binaryDataService.store.mockImplementation(async (_l, _b, binaryData) => binaryData); }); it('parses filenames correctly', async () => { @@ -644,13 +644,17 @@ describe('prepareBinaryData', () => { const result = await prepareBinaryData(buffer, executionId, workflowId, fileName); expect(result.fileName).toEqual(fileName); - expect(binaryDataService.store).toHaveBeenCalledWith(workflowId, executionId, buffer, { - data: '', - fileExtension: undefined, - fileName, - fileType: 'text', - mimeType: 'text/plain', - }); + expect(binaryDataService.store).toHaveBeenCalledWith( + { type: 'execution', executionId, workflowId }, + buffer, + { + data: '', + fileExtension: undefined, + fileName, + fileType: 'text', + mimeType: 'text/plain', + }, + ); }); it('handles IncomingMessage with responseUrl', async () => { diff --git a/packages/core/src/execution-engine/node-execution-context/utils/binary-helper-functions.ts b/packages/core/src/execution-engine/node-execution-context/utils/binary-helper-functions.ts index 01329df51bd..50bef3a970a 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/binary-helper-functions.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/binary-helper-functions.ts @@ -159,8 +159,7 @@ export async function setBinaryDataBuffer( executionId: string, ): Promise { return await Container.get(BinaryDataService).store( - workflowId, - executionId, + { type: 'execution', workflowId, executionId }, bufferOrStream, binaryData, ); @@ -218,8 +217,7 @@ export async function copyBinaryFile( } return await Container.get(BinaryDataService).copyBinaryFile( - workflowId, - executionId, + { type: 'execution', workflowId, executionId }, returnData, filePath, ); diff --git a/packages/frontend/@n8n/chat/src/components/ChatFile.vue b/packages/frontend/@n8n/chat/src/components/ChatFile.vue index 52b4acf789a..890fd0cbf1d 100644 --- a/packages/frontend/@n8n/chat/src/components/ChatFile.vue +++ b/packages/frontend/@n8n/chat/src/components/ChatFile.vue @@ -11,6 +11,7 @@ const props = defineProps<{ file: File; isRemovable: boolean; isPreviewable?: boolean; + href?: string; }>(); const emit = defineEmits<{ @@ -30,6 +31,11 @@ const TypeIcon = computed(() => { }); function onClick() { + if (props.href) { + window.open(props.href, '_blank', 'noopener noreferrer'); + return; + } + if (props.isPreviewable) { window.open(URL.createObjectURL(props.file)); } @@ -41,12 +47,12 @@ function onDelete() { @@ -64,7 +70,14 @@ function onDelete() { background: white; color: var(--chat--color-dark); border: 1px solid var(--chat--color-dark); - cursor: pointer; + + &:has(.chat-file-preview) { + cursor: pointer; + } +} + +.chat-icon { + flex-shrink: 0; } .chat-file-name-tooltip { diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts index 37e20f93b97..e49dd281e25 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts @@ -188,6 +188,7 @@ import IconLucideSmile from '~icons/lucide/smile'; import IconLucideSparkles from '~icons/lucide/sparkles'; import IconLucideSquare from '~icons/lucide/square'; import IconLucideSquareCheck from '~icons/lucide/square-check'; +import IconLucideSquareMinus from '~icons/lucide/square-minus'; import IconLucideSquarePen from '~icons/lucide/square-pen'; import IconLucideSquarePlus from '~icons/lucide/square-plus'; import IconLucideStickyNote from '~icons/lucide/sticky-note'; @@ -626,6 +627,7 @@ export const updatedIconSet = { sparkles: IconLucideSparkles, square: IconLucideSquare, 'square-check': IconLucideSquareCheck, + 'square-minus': IconLucideSquareMinus, 'square-pen': IconLucideSquarePen, 'square-plus': IconLucideSquarePlus, 'sticky-note': IconLucideStickyNote, diff --git a/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue b/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue index cf2bce06239..47dde4ce2d0 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue @@ -42,7 +42,14 @@ const { t } = useI18n();
- + {{ tag.text }} { } .danger { - --border-color: var(--color--danger--tint-3); - --notice--color--background: var(--color--danger--tint-4); + --border-color: var(--callout--border-color--danger); + --notice--color--background: var(--callout--color--background--danger); } .success { - --border-color: var(--color--success--tint-3); - --notice--color--background: var(--color--success--tint-4); + --border-color: var(--callout--border-color--success); + --notice--color--background: var(--callout--color--background--success); } .info { diff --git a/packages/frontend/@n8n/design-system/src/components/N8nPromptInput/N8nPromptInput.vue b/packages/frontend/@n8n/design-system/src/components/N8nPromptInput/N8nPromptInput.vue index adfa95eec25..814e2869a3e 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nPromptInput/N8nPromptInput.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nPromptInput/N8nPromptInput.vue @@ -423,6 +423,7 @@ defineExpose({ @@ -432,6 +433,8 @@ defineExpose({ :disabled="!showAskOwnerTooltip" :content="t('promptInput.askAdminToUpgrade')" placement="top" + :show-after="300" + :enterable="false" > {{ t('promptInput.getMore') }} diff --git a/packages/frontend/@n8n/design-system/src/locale/lang/en.ts b/packages/frontend/@n8n/design-system/src/locale/lang/en.ts index 124823655dc..5db679734b2 100644 --- a/packages/frontend/@n8n/design-system/src/locale/lang/en.ts +++ b/packages/frontend/@n8n/design-system/src/locale/lang/en.ts @@ -86,7 +86,7 @@ export default { 'promptInput.askAdminToUpgrade': 'Ask your admin to upgrade the instance to get more credits', 'promptInput.characterLimitReached': "You've reached the {limit} character limit", 'promptInput.remainingCredits': 'Remaining builder AI credits: {count}', - 'promptInput.monthlyCredits': 'Monthly credits: {count}', + 'promptInput.monthlyCredits': 'Monthly credits: {count} (1 credit = 1 message)', 'promptInput.creditsRenew': 'Credits renew on: {date}', 'promptInput.creditsExpire': 'Unused credits expire {date}', } as N8nLocale; diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Badge/component-badge.md b/packages/frontend/@n8n/design-system/src/v2/components/Badge/component-badge.md new file mode 100644 index 00000000000..07cd48b9388 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/v2/components/Badge/component-badge.md @@ -0,0 +1,220 @@ +# Component specification + +Displays short text or icons to represent status, categories, or counts. Badges help users quickly identify important information through visual indicators without cluttering the interface. + +- **Component Name:** N8nBadge +- **Figma Component:** TBD +- **Current Implementation:** Custom component (no Element+ dependency) +- **Reka UI Component:** N/A (Reka UI does not provide a Badge primitive) +- **Nuxt UI Reference:** [Badge](https://ui.nuxt.com/docs/components/badge) (custom implementation, not based on Reka UI) + +## Public API Definition + +**Props** + +- `theme?: BadgeTheme` - Visual style variant for the badge. Values: `'default' | 'success' | 'warning' | 'danger' | 'primary' | 'secondary' | 'tertiary'`. Default: `'default'` + - `default`: Subtle border badge with neutral styling + - `success`: Green indicator for positive states + - `warning`: Yellow/orange indicator for cautionary states + - `danger`: Red indicator for error/critical states + - `primary`: Filled badge with contrasting text (pill-shaped) + - `secondary`: Filled badge with tinted background + - `tertiary`: Minimal badge variant with reduced padding +- `size?: TextSize` - Size of the badge text. Values: `'xsmall' | 'small' | 'mini' | 'medium' | 'large' | 'xlarge'`. Default: `'small'` +- `bold?: boolean` - Whether to render text in bold weight. Default: `false` +- `showBorder?: boolean` - Whether to show the badge border. Default: `true` + +**Slots** + +- `default` - Badge content (text, icons, or mixed content). Wrapped in N8nText component for consistent typography. + +### Template usage examples + +**Simple text badge:** +```typescript + + + +``` + +**Status indicators:** +```typescript + + + +``` + +**Count badge (primary theme):** +```typescript + + + +``` + +**Tertiary minimal label:** +```typescript + + + +``` + +**Badge with icon and text:** +```typescript + + + +``` + +**Multiple sizes:** +```typescript + + + +``` + +**With custom CSS classes:** +```typescript + + + +``` + +**Conditional rendering:** +```typescript + + + +``` + +**With inline styles (advanced):** +```typescript + + + +``` + +**Dynamic theme binding:** +```typescript + + + +``` diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Tooltip/component-tooltip.md b/packages/frontend/@n8n/design-system/src/v2/components/Tooltip/component-tooltip.md new file mode 100644 index 00000000000..26b6ef6868e --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/v2/components/Tooltip/component-tooltip.md @@ -0,0 +1,148 @@ +# Component specification + +Displays contextual information when users hover over, focus on, or tap an element. Tooltips help users understand the purpose or state of UI elements without cluttering the interface. + +- **Component Name:** N8nTooltip +- **Figma Component:** [Figma](https://www.figma.com/design/8zib7Trf2D2CHYXrEGPHkg/n8n-Design-System-V3?node-id=252-3284&m=dev) +- **Element+ Component:** [ElTooltip](https://element-plus.org/en-US/component/tooltip.html) +- **Reka UI Component:** [Tooltip](https://reka-ui.com/docs/components/tooltip) +- **Nuxt UI Component:** [Tooltip](https://ui.nuxt.com/docs/components/tooltip) + + +## Public API Definition + +**Props** + +- `content?: string` - Text content for the tooltip. Supports HTML (sanitized for security). +- `placement?: Placement` - Position of tooltip relative to trigger. Values: `'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end' | 'right' | 'right-start' | 'right-end'`. Default: `'top'` +- `disabled?: boolean` - When `true`, prevents the tooltip from showing. +- `showAfter?: number` - Delay in milliseconds before showing tooltip after trigger is hovered. Default: `0` +- `visible?: boolean` - Manual control of tooltip visibility (programmatic show/hide). +- `popperClass?: string` - Custom CSS class name for the tooltip popper element. +- `enterable?: boolean` - Whether the mouse can enter the tooltip content area. Default: `true` +- `popperOptions?: object` - Popper.js configuration object for advanced positioning control. +- `teleported?: boolean` - Whether to append the tooltip to the body. Default: `true` +- `offset?: number` - Offset of the tooltip from the trigger element (in pixels). +- `showArrow?: boolean` - Whether to show the tooltip arrow. Default: `true` + +**Slots** + +- `default` - The trigger element that activates the tooltip (required). +- `content` - Custom content for the tooltip body. When not provided, renders `content` prop with HTML sanitization. + +### Template usage example + +**Simple text tooltip:** +```typescript + + + +``` + +**Custom content slot:** +```typescript + + + +``` + +**Delayed tooltip:** +```typescript + + + +``` + +**Programmatically controlled visibility:** +```typescript + + + +``` + +**Custom popper class:** +```typescript + + + +``` + +**Disabled tooltip:** +```typescript + + + +``` diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index e9e7daba1ec..2ba9c5269be 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -62,6 +62,7 @@ "generic.folder": "Folder", "generic.keepBuilding": "Keep building", "generic.learnMore": "Learn more", + "generic.recommended": "Recommended", "generic.reset": "Reset", "generic.resetAllFilters": "Reset all filters", "generic.communityNode": "Community Node", @@ -1486,7 +1487,6 @@ "nodeCreator.triggerHelperPanel.manualTriggerDescription": "Runs the flow on clicking a button in n8n. Good for getting started quickly", "nodeCreator.triggerHelperPanel.manualChatTriggerDisplayName": "On chat message", "nodeCreator.triggerHelperPanel.manualChatTriggerDescription": "Runs the flow when a user sends a chat message. For use with AI nodes", - "nodeCreator.triggerHelperPanel.manualTriggerTag": "Recommended", "nodeCreator.triggerHelperPanel.chatTriggerDisplayName": "On chat message", "nodeCreator.triggerHelperPanel.chatTriggerDescription": "Runs the flow when a user sends a chat message. For use with AI nodes", "nodeCreator.triggerHelperPanel.whatHappensNext": "What happens next?", @@ -1773,7 +1773,7 @@ "nodeWebhooks.webhookUrls.mcpTrigger": "MCP URL", "openWorkflow.workflowImportError": "Could not import workflow", "openWorkflow.workflowNotFoundError": "Could not find workflow", - "oauth.consentView.title": "oAuth access consent", + "oauth.consentView.title": "OAuth access consent", "oauth.consentView.heading": "{clientName} wants access to your n8n instance", "oauth.consentView.description": "This will allow {clientName} to perform the following actions:", "oauth.consentView.action.listWorkflows": "Get a list of your workflows", @@ -2254,7 +2254,7 @@ "settings.mcp.empty.description": "Enable MCP access in each workflow's settings to see them here.", "settings.mcp.toggle.disabled.tooltip": "Only instance admins can change this", "settings.mcp.toggle.error": "Error updating MCP access", - "settings.mcp.instructions.tabs.oauth": "oAuth", + "settings.mcp.instructions.tabs.oauth": "OAuth", "settings.mcp.instructions.tabs.apiKey": "Access Token", "settings.mcp.instructions.enableAccess": "Enable workflow access in at least one workflow via its settings", "settings.mcp.instructions.serverUrl": "Server URL", @@ -2268,7 +2268,7 @@ "settings.mcp.error.fetching.apiKey": "Error fetching access token", "settings.mcp.error.rotating.apiKey": "Error generating new access token", "settings.mcp.error.fetching.oAuthClients": "Error fetching list of OAuth clients", - "settings.mcp.oAuthClients.heading": "Connected oAuth clients", + "settings.mcp.oAuthClients.heading": "Connected OAuth clients", "settings.mcp.oAuthClients.table.clientName": "Client Name", "settings.mcp.oAuthClients.table.connectedAt": "Connected At", "settings.mcp.oAuthClients.table.lastUsedAt": "Last Used At", @@ -2276,7 +2276,7 @@ "settings.mcp.oAuthClients.revoke.success.title": "Access revoked", "settings.mcp.oAuthClients.revoke.success.message": "Client {name} access has been revoked", "settings.mcp.oAuthClients.revoke.error": "Error revoking client access", - "settings.mcp.oAuthClients.table.empty.title": "No oAuth clients connected", + "settings.mcp.oAuthClients.table.empty.title": "No OAuth clients connected", "settings.mcp.refresh.tooltip": "Refresh list", "settings.mcp.workflowsTable.workflow": "Workflow", "settings.goBack": "Go back", @@ -2509,12 +2509,24 @@ "settings.provisioning.scopesProjectsRolesClaimName.help": "The claim name used to provision projects and their roles from Oauth. For SAML / LDAP, this will be the attribute name checked.", "settings.provisioning.toggle": "Provision instance and project roles", "settings.provisioning.toggle.help": "Project access can only be defined on external provider. Any existing project access configured in n8n, but not on the provider, will be removed once a user logs in.", - "settings.provisioningConfirmDialog.title": "Enable Just-in-time provisioning (JIT)", + "settings.provisioningConfirmDialog.enable.title": "Enable user role provisioning", + "settings.provisioningConfirmDialog.disable.title": "Disable user role provisioning", "settings.provisioningConfirmDialog.breakingChangeDescription.firstLine": "When you enable Just-in-time provisioning, your external SSO provider becomes the source of truth for all instance and project roles in n8n.", "settings.provisioningConfirmDialog.breakingChangeDescription.list.one": "If your SSO provider doesn't specify a role for a member, we'll automatically assign the default role: global:member.", "settings.provisioningConfirmDialog.breakingChangeDescription.list.two": "Any existing instance and project roles in n8n will be replaced by the roles defined in your SSO provider once the user logs in via SSO.", "settings.provisioningConfirmDialog.breakingChangeRequiredSteps": "To enable you to migrate your current access settings to your SSO provider, download the two CSV files below. This step is mandatory before enabling JIT.", - "settings.provisioningConfirmDialog.button.confirm": "Activate JIT", + "settings.provisioningConfirmDialog.disable.description": "You're switching instance role management back to n8n.", + "settings.provisioningConfirmDialog.disable.whatWillHappen": "What will happen:", + "settings.provisioningConfirmDialog.disable.list.one": "The SSO n8n_instance_role attribute will be ignored.", + "settings.provisioningConfirmDialog.disable.list.two": "Instance roles must be reassigned manually inside n8n.", + "settings.provisioningConfirmDialog.disable.beforeSaving": "Before saving, make sure:", + "settings.provisioningConfirmDialog.disable.checklist.one": "You are ready to reassign instance roles for all users inside n8n.", + "settings.provisioningConfirmDialog.disable.checklist.two": "You understand that role changes made in SSO will no longer be applied.", + "settings.provisioningConfirmDialog.enable.checkbox": "I have downloaded and reviewed the CSV export. My SSO provider is correctly configured to become the source of truth for user role provisioning on this n8n instance.", + "settings.provisioningConfirmDialog.disable.checkbox": "I confirm that I want to no longer provision user roles from my SSO provider.", + "settings.provisioningConfirmDialog.link.docs": "Link to docs", + "settings.provisioningConfirmDialog.button.enable.confirm": "Save and enable", + "settings.provisioningConfirmDialog.button.disable.confirm": "Save and disable", "settings.provisioningConfirmDialog.button.cancel": "Cancel", "settings.provisioningConfirmDialog.button.generateCsvExport": "Generate access settings CSV export", "settings.provisioningConfirmDialog.button.downloadProjectRolesCsv": "Download existing project access settings csv", @@ -3442,6 +3454,15 @@ "settings.sso.settings.oidc.prompt.consent": "Consent (Ask the user to consent)", "settings.sso.settings.oidc.prompt.select_account": "Select Account (Allow the user to select an account)", "settings.sso.settings.oidc.prompt.create": "Create (Ask the OP to show the registration page first)", + "settings.sso.settings.userRoleProvisioning.label": "User role provisioning", + "settings.sso.settings.userRoleProvisioning.help": "Manage instance and project roles from your SSO provider.", + "settings.sso.settings.userRoleProvisioning.help.linkText": "Link to docs", + "settings.sso.settings.userRoleProvisioning.option.disabled.label": "Disabled", + "settings.sso.settings.userRoleProvisioning.option.disabled.description": "User and project roles are managed inside the n8n settings.", + "settings.sso.settings.userRoleProvisioning.option.instanceRole.label": "Instance role", + "settings.sso.settings.userRoleProvisioning.option.instanceRole.description": "The instance role of a user is configured in the \"n8n_instance_role\" attribute on your SSO provider. If none is set on the SSO provider, the member role is used as fallback.", + "settings.sso.settings.userRoleProvisioning.option.instanceAndProjectRoles.label": "Instance and project roles", + "settings.sso.settings.userRoleProvisioning.option.instanceAndProjectRoles.description": "The list of projects a user has access to is configured on the \"n8n_projects\" string array attribute on your SSO provider. Project access cannot be granted from within n8n.", "settings.sso.settings.test": "Test settings", "settings.sso.settings.save": "Save settings", "settings.sso.settings.save.activate.title": "Test and activate SAML SSO", diff --git a/packages/frontend/@n8n/rest-api-client/src/api/workflows.ts b/packages/frontend/@n8n/rest-api-client/src/api/workflows.ts index 44b1d6985dc..264f3e9b7f7 100644 --- a/packages/frontend/@n8n/rest-api-client/src/api/workflows.ts +++ b/packages/frontend/@n8n/rest-api-client/src/api/workflows.ts @@ -20,6 +20,7 @@ export interface WorkflowData { tags?: string[]; pinData?: IPinData; versionId?: string; + activeVersionId?: string | null; meta?: WorkflowMetadata; } diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index 20032ec3ec7..1fcb8042fcf 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -249,6 +249,7 @@ export interface IWorkflowDb { homeProject?: ProjectSharingData; scopes?: Scope[]; versionId: string; + activeVersionId: string | null; usedCredentials?: IUsedCredential[]; meta?: WorkflowMetadata; parentFolder?: { @@ -276,6 +277,7 @@ export type WorkflowResource = BaseResource & { updatedAt: string; createdAt: string; active: boolean; + activeVersionId: string | null; isArchived: boolean; homeProject?: ProjectSharingData; scopes?: Scope[]; @@ -346,6 +348,7 @@ export interface IWorkflowShortResponse { id: string; name: string; active: boolean; + activeVersionId: string | null; createdAt: number | string; updatedAt: number | string; tags: ITag[]; @@ -444,7 +447,7 @@ export type SimplifiedNodeType = Pick< | 'defaults' | 'outputs' > & { - tag?: string; + tag?: NodeCreatorTag; }; export interface SubcategoryItemProps { description?: string; diff --git a/packages/frontend/editor-ui/src/__tests__/mocks.ts b/packages/frontend/editor-ui/src/__tests__/mocks.ts index a0c7bf47d24..e841a47670c 100644 --- a/packages/frontend/editor-ui/src/__tests__/mocks.ts +++ b/packages/frontend/editor-ui/src/__tests__/mocks.ts @@ -200,7 +200,8 @@ export function createTestWorkflow({ active, isArchived, settings, - versionId: '1', + versionId: 'v1', + activeVersionId: active ? 'v1' : null, meta: {}, pinData, ...rest, diff --git a/packages/frontend/editor-ui/src/__tests__/server/factories/workflow.ts b/packages/frontend/editor-ui/src/__tests__/server/factories/workflow.ts index 555b03e06c7..cc1a60b6485 100644 --- a/packages/frontend/editor-ui/src/__tests__/server/factories/workflow.ts +++ b/packages/frontend/editor-ui/src/__tests__/server/factories/workflow.ts @@ -12,6 +12,9 @@ export const workflowFactory = Factory.extend({ active() { return faker.datatype.boolean(); }, + activeVersionId(i: number) { + return this.active ? i.toString() : null; + }, isArchived() { return faker.datatype.boolean(); }, diff --git a/packages/frontend/editor-ui/src/app/App.vue b/packages/frontend/editor-ui/src/app/App.vue index 02ae96b0619..3a421fe0b5a 100644 --- a/packages/frontend/editor-ui/src/app/App.vue +++ b/packages/frontend/editor-ui/src/app/App.vue @@ -3,9 +3,9 @@ import AssistantsHub from '@/features/ai/assistant/components/AssistantsHub.vue' import AskAssistantFloatingButton from '@/features/ai/assistant/components/Chat/AskAssistantFloatingButton.vue'; import BannerStack from '@/features/shared/banners/components/BannerStack.vue'; import Modals from '@/app/components/Modals.vue'; -import Telemetry from '@/app/components/Telemetry.vue'; import { useHistoryHelper } from '@/app/composables/useHistoryHelper'; import { useTelemetryContext } from '@/app/composables/useTelemetryContext'; +import { useTelemetryInitializer } from '@/app/composables/useTelemetryInitializer'; import { useWorkflowDiffRouting } from '@/app/composables/useWorkflowDiffRouting'; import { APP_MODALS_ELEMENT_ID, @@ -63,6 +63,8 @@ useHistoryHelper(route); // Initialize workflow diff routing management useWorkflowDiffRouting(); +useTelemetryInitializer(); + const loading = ref(true); const defaultLocale = computed(() => rootStore.defaultLocale); const isDemoMode = computed(() => route.name === VIEWS.DEMO); @@ -181,7 +183,6 @@ useExposeCssVar('--ask-assistant--floating-button--margin-bottom', askAiFloating @input-change="onCommandBarChange" @navigate-to="onCommandBarNavigateTo" /> -
diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.test.ts b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.test.ts index 93d531d4eaf..14039b7e09b 100644 --- a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.test.ts +++ b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.test.ts @@ -66,6 +66,7 @@ describe('WorkflowDescriptionPopover', () => { id: 'test-workflow-id', name: 'Test Workflow', active: false, + activeVersionId: null, isArchived: false, createdAt: Date.now(), updatedAt: Date.now(), @@ -596,6 +597,7 @@ describe('WorkflowDescriptionPopover', () => { id: 'test-workflow-id', name: 'Test Workflow', active: false, + activeVersionId: null, isArchived: false, createdAt: Date.now(), updatedAt: Date.now(), @@ -642,6 +644,7 @@ describe('WorkflowDescriptionPopover', () => { id: 'test-workflow-id', name: 'Test Workflow', active: false, + activeVersionId: null, isArchived: false, createdAt: Date.now(), updatedAt: Date.now(), diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDetails.vue b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDetails.vue index 7e627be65f5..a60ccdc3832 100644 --- a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDetails.vue +++ b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDetails.vue @@ -47,7 +47,7 @@ import type { FolderShortInfo } from '@/features/core/folders/folders.types'; import { useFoldersStore } from '@/features/core/folders/folders.store'; import { useNpsSurveyStore } from '@/app/stores/npsSurvey.store'; import { ProjectTypes } from '@/features/collaboration/projects/projects.types'; -import { sanitizeFilename } from '@/app/utils/fileUtils'; +import { sanitizeFilename } from '@n8n/utils/files/sanitize'; import { hasPermission } from '@/app/utils/rbac/permissions'; import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue'; import { type BaseTextKey, useI18n } from '@n8n/i18n'; diff --git a/packages/frontend/editor-ui/src/app/components/SettingsSidebar.vue b/packages/frontend/editor-ui/src/app/components/SettingsSidebar.vue index c6f833de49f..66eade0ca3e 100644 --- a/packages/frontend/editor-ui/src/app/components/SettingsSidebar.vue +++ b/packages/frontend/editor-ui/src/app/components/SettingsSidebar.vue @@ -1,7 +1,6 @@ - -