Merge branch 'master' into fix-ai-1668-thinking-model-error

This commit is contained in:
Michael Drury 2025-11-20 16:47:05 +00:00 committed by GitHub
commit 3984bc3937
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
276 changed files with 8141 additions and 3274 deletions

View File

@ -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<<EOF"
echo -e "$ALL_TAGS"
echo "EOF"
echo "ghcr_platform_tag=${GHCR_TAGS_FOR_PUSH}"
echo "dockerhub_platform_tag=${DOCKER_TAGS_FOR_PUSH}"
} >> "$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<<EOF" ]]; then
echo "tags_distroless<<EOF" >> "$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"

171
.github/scripts/docker/docker-config.mjs vendored Normal file
View File

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

113
.github/scripts/docker/docker-tags.mjs vendored Normal file
View File

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

View File

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

View File

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

View File

@ -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<<EOF"
echo -e "$ALL_TAGS"
echo "EOF"
} >> "$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' ||

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
# ==============================================================================

View File

@ -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
# ==============================================================================

View File

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

View File

@ -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"
}
}

View File

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

View File

@ -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 <node_selection> 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 <workflow_validation_report> and resolve any violations before finalizing
- Why: Ensures structural issues are surfaced early; rerun validation after major updates
<node_selection>
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.
</node_selection>
<best_practices_compliance>
Enforcing best practice compliance is MANDATORY

View File

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

View File

@ -17,6 +17,9 @@ export const chatHubLLMProviderSchema = z.enum([
'google',
'azureOpenAi',
'ollama',
'awsBedrock',
'cohere',
'mistralCloud',
]);
export type ChatHubLLMProvider = z.infer<typeof chatHubLLMProviderSchema>;
@ -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<typeof anthropicModelSchema>;
export type ChatHubGoogleModel = z.infer<typeof googleModelSchema>;
export type ChatHubAzureOpenAIModel = z.infer<typeof azureOpenAIModelSchema>;
export type ChatHubOllamaModel = z.infer<typeof ollamaModelSchema>;
export type ChatHubAwsBedrockModel = z.infer<typeof awsBedrockModelSchema>;
export type ChatHubCohereModel = z.infer<typeof cohereModelSchema>;
export type ChatHubMistralCloudModel = z.infer<typeof mistralCloudModelSchema>;
export type ChatHubBaseLLMModel =
| ChatHubOpenAIModel
| ChatHubAnthropicModel
| ChatHubGoogleModel
| ChatHubAzureOpenAIModel
| ChatHubOllamaModel;
| ChatHubOllamaModel
| ChatHubAwsBedrockModel
| ChatHubCohereModel
| ChatHubMistralCloudModel;
export type ChatHubN8nModel = z.infer<typeof n8nModelSchema>;
export type ChatHubCustomAgentModel = z.infer<typeof chatAgentSchema>;
@ -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<typeof chatAttachmentSchema>;
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<ChatMessageId, ChatHubMessageDto>;

View File

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

View File

@ -27,10 +27,13 @@ export {
emptyChatModelsResponse,
type ChatModelsRequest,
type ChatModelsResponse,
chatAttachmentSchema,
type ChatAttachment,
ChatHubSendMessageRequest,
ChatHubRegenerateMessageRequest,
ChatHubEditMessageRequest,
ChatHubUpdateConversationRequest,
ChatHubConversationsRequest,
type ChatMessageId,
type ChatSessionId,
type ChatHubMessageDto,

View File

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

View File

@ -89,6 +89,18 @@ export async function createManyWorkflows(
return await Promise.all(workflowRequests);
}
export async function createManyActiveWorkflows(
amount: number,
attributes: Partial<IWorkflowDb> = {},
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<DeepPartial<SharedWorkflow>> = await Promise.all(
users.map(async (user) => {
@ -135,7 +147,7 @@ export async function getWorkflowSharing(workflow: IWorkflowBase) {
*/
export async function createWorkflowWithTrigger(
attributes: Partial<IWorkflowDb> = {},
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<IWorkflowDb> = {},
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<void> {
export async function createWorkflowHistory(
workflow: IWorkflowDb,
userOrProject?: User | Project,
): Promise<void> {
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<void> {
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<IWorkflowDb> = {},
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<IWorkflowDb> = {},
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;
}

View File

@ -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<string>();
// 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');
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -408,7 +408,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
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<ExecutionEntity> {
}
const ids = executions.map(({ id, workflowId }) => ({
type: 'execution' as const,
executionId: id,
workflowId,
}));
@ -605,7 +606,11 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
*/
withDeleted: true,
})
).map(({ id: executionId, workflowId }) => ({ workflowId, executionId }));
).map(({ id: executionId, workflowId }) => ({
type: 'execution' as const,
workflowId,
executionId,
}));
return workflowIdsAndExecutionIds;
}

View File

@ -58,7 +58,7 @@ export class LicenseMetricsRepository extends Repository<LicenseMetrics> {
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,

View File

@ -149,6 +149,7 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
where?: FindOptionsWhere<SharedWorkflow>;
includeTags?: boolean;
includeParentFolder?: boolean;
includeActiveVersion?: boolean;
em?: EntityManager;
} = {},
) {
@ -156,6 +157,7 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
where = {},
includeTags = false,
includeParentFolder = false,
includeActiveVersion = false,
em = this.manager,
} = options;
@ -169,6 +171,7 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
shared: { project: { projectRelations: { user: true } } },
tags: includeTags,
parentFolder: includeParentFolder,
activeVersion: includeActiveVersion,
},
},
});

View File

@ -14,9 +14,9 @@ export class WorkflowHistoryRepository extends Repository<WorkflowHistory> {
}
/**
* 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<WorkflowHistory> {
.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();
}
}

View File

@ -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<WorkflowStatistics>
role: 'workflow:owner',
project: { projectRelations: { userId, role: { slug: PROJECT_OWNER_ROLE_SLUG } } },
},
active: true,
activeVersionId: Not(IsNull()),
},
name: StatisticsNames.productionSuccess,
count: MoreThanOrEqual(5),

View File

@ -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<WorkflowEntity> {
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<WorkflowEntity> {
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<WorkflowEntity> {
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<WorkflowEntity> {
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<WorkflowEntity> {
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<WorkflowEntity> {
'workflow.createdAt',
'workflow.updatedAt',
'workflow.versionId',
'workflow.activeVersionId',
'workflow.settings',
'workflow.description',
]);
@ -806,19 +811,42 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
}
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<WorkflowEntity> {
);
const workflows: Array<
Pick<WorkflowEntity, 'id' | 'name' | 'active'> & Partial<Pick<WorkflowEntity, 'nodes'>>
Pick<WorkflowEntity, 'id' | 'name' | 'active' | 'activeVersionId'> &
Partial<Pick<WorkflowEntity, 'nodes'>>
> = await qb
.select([
'workflow.id',
'workflow.name',
'workflow.active',
'workflow.activeVersionId',
...(includeNodes ? ['workflow.nodes'] : []),
])
.where(whereClause, parameters)

View File

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

View File

@ -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<ContextEstablishmentResult> {
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<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
@ContextEstablishmentHook()
class SecondHook implements IContextEstablishmentHook {
hookDescription = { name: 'second.hook' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
@ContextEstablishmentHook()
class ThirdHook implements IContextEstablishmentHook {
hookDescription = { name: 'third.hook' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
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<ContextEstablishmentResult> {
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<ContextEstablishmentResult> {
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<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
@ContextEstablishmentHook()
class ApiKeyHook implements IContextEstablishmentHook {
hookDescription = { name: 'credentials.apiKey' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
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');
});
});

View File

@ -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<ContextEstablishmentHookEntry> = 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 =
<T extends ContextEstablishmentHookClass>() =>
(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);
};

View File

@ -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<string, unknown>;
};
/**
* 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<PlaintextExecutionContext>;
};
/**
* 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<ContextEstablishmentResult> {
* 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<ContextEstablishmentResult>;
/**
* 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<IContextEstablishmentHook>;

View File

@ -0,0 +1,5 @@
export {
ContextEstablishmentHookMetadata,
ContextEstablishmentHook,
} from './context-establishment-hook-metadata';
export type * from './context-establishment-hook';

View File

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

View File

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

View File

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

View File

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

View File

@ -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": [

View File

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

View File

@ -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<RequestResponseMetadata> = {
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 () => {

View File

@ -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({

View File

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

View File

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

View File

@ -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": [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,3 +7,4 @@ export * from './search/reRankSearchResults';
export * from './search/sublimeSearch';
export * from './sort/sortByProperty';
export * from './string/truncate';
export * from './files/sanitize';

View File

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

View File

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

View File

@ -65,6 +65,7 @@ describe('ActiveExecutions', () => {
id: '123',
name: 'Test workflow 1',
active: false,
activeVersionId: null,
isArchived: false,
createdAt: new Date(),
updatedAt: new Date(),

View File

@ -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<WorkflowEntity>({ active: false }));
workflowRepository.findById.mockResolvedValue(
mock<WorkflowEntity>({ 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<WorkflowHistory>({
versionId: 'v1',
workflowId: 'workflow-1',
nodes: activeNodes,
connections: {},
authors: 'test-user',
createdAt: new Date(),
updatedAt: new Date(),
});
const workflowEntity = mock<WorkflowEntity>({
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');
});
});
});

View File

@ -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<WorkflowEntity>({ id: EXECUTION_ID, nodes: [] }),
mock<WorkflowEntity>({
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<INode>({
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<INode>({
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<WorkflowEntity>({
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<INode>({
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<WorkflowEntity>({
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<WorkflowEntity>({
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<IWorkflowBase>({
id: 'code-workflow',
name: 'Code Workflow',
active: false,
nodes: [
mock<INode>({
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<IWorkflowBase>({
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);

View File

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

View File

@ -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`,
];
}

View File

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

View File

@ -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<SourceControlScopedService>();
const variableService = mock<VariablesService>();
const variablesRepository = mock<VariablesRepository>();
const activeWorkflowManager = mock<ActiveWorkflowManager>();
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<SourceControlledFile>({ 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<SourceControlledFile>({ 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<SourceControlledFile>({ 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<SourceControlledFile>({ 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<SourceControlledFile>({ 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', () => {

View File

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

View File

@ -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) {

View File

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

View File

@ -1215,6 +1215,7 @@ describe('TelemetryEventRelay', () => {
id: 'workflow123',
name: 'Test Workflow',
active: true,
activeVersionId: 'some-version-id',
nodes: [
{
id: 'node1',

View File

@ -62,6 +62,7 @@ describe('Execution Lifecycle Hooks', () => {
id: workflowId,
name: 'Test Workflow',
active: true,
activeVersionId: 'some-version-id',
isArchived: false,
connections: {},
nodes: [

View File

@ -38,6 +38,7 @@ export function prepareExecutionDataForDbUpdate(parameters: {
'id',
'name',
'active',
'activeVersionId',
'isArchived',
'createdAt',
'updatedAt',

View File

@ -183,6 +183,7 @@ export class ExecutionService {
const executionMode = 'retry';
execution.workflowData.active = false;
execution.workflowData.activeVersionId = null;
// Start the workflow
const data: IWorkflowExecutionDataProcess = {

View File

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

View File

@ -6,6 +6,7 @@ export class WorkflowSelect extends BaseSelect {
'id', // always included downstream
'name',
'active',
'activeVersionId',
'tags',
'createdAt',
'updatedAt',

View File

@ -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: [
{

View File

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

View File

@ -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<BinaryDataConfig>();
const binaryDataConfig: BinaryDataConfig = mock<BinaryDataConfig>();
const executionsConfig: ExecutionsConfig = mock<ExecutionsConfig>();
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);
});
});
});

View File

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

View File

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

View File

@ -32,6 +32,7 @@ export class ChatHubAgentService {
},
createdAt: agent.createdAt.toISOString(),
updatedAt: agent.updatedAt.toISOString(),
allowFileUploads: true,
})),
};
}

View File

@ -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<IBinaryData> | null;
}

View File

@ -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>): 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: {

View File

@ -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<IBinaryData[]> {
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<ArrayBufferLike>; 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<void> {
const messages = await this.messageRepository.getManyBySessionId(sessionId);
await this.deleteAttachments(messages.flatMap((message) => message.attachments ?? []));
}
/**
* Deletes all chat attachment files.
*/
async deleteAll(): Promise<void> {
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<void> {
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<IBinaryData> {
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,
);
}
}

View File

@ -32,6 +32,18 @@ export const PROVIDER_NODE_TYPE_MAP: Record<ChatHubLLMProvider, INodeTypeNameVer
name: '@n8n/n8n-nodes-langchain.lmChatAzureOpenAi',
version: 1,
},
awsBedrock: {
name: '@n8n/n8n-nodes-langchain.lmChatAwsBedrock',
version: 1.1,
},
cohere: {
name: '@n8n/n8n-nodes-langchain.lmChatCohere',
version: 1,
},
mistralCloud: {
name: '@n8n/n8n-nodes-langchain.lmChatMistralCloud',
version: 1,
},
};
export const NODE_NAMES = {
@ -42,6 +54,7 @@ export const NODE_NAMES = {
MEMORY: 'Memory',
RESTORE_CHAT_MEMORY: 'Restore Chat Memory',
CLEAR_CHAT_MEMORY: 'Clear Chat Memory',
MERGE: 'Merge',
} as const;
/* eslint-disable @typescript-eslint/naming-convention */

View File

@ -10,6 +10,7 @@ import {
ChatMessageId,
ChatHubCreateAgentRequest,
ChatHubUpdateAgentRequest,
ChatHubConversationsRequest,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { AuthenticatedRequest } from '@n8n/db';
@ -22,13 +23,17 @@ import {
Delete,
Param,
Patch,
Query,
} from '@n8n/decorators';
import type { Response } from 'express';
import { strict as assert } from 'node:assert';
import { ChatHubAgentService } from './chat-hub-agent.service';
import { ChatHubAttachmentService } from './chat-hub.attachment.service';
import { ChatHubService } from './chat-hub.service';
import { ChatModelsRequestDto } from './dto/chat-models-request.dto';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { sanitizeFilename } from '@n8n/utils';
import { ResponseError } from '@/errors/response-errors/abstract/response.error';
@ -37,6 +42,7 @@ export class ChatHubController {
constructor(
private readonly chatService: ChatHubService,
private readonly chatAgentService: ChatHubAgentService,
private readonly chatAttachmentService: ChatHubAttachmentService,
private readonly logger: Logger,
) {}
@ -55,8 +61,9 @@ export class ChatHubController {
async getConversations(
req: AuthenticatedRequest,
_res: Response,
@Query query: ChatHubConversationsRequest,
): Promise<ChatHubConversationsResponse> {
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<void>((resolve, reject) => {
attachmentAsStreamOrBuffer.stream.on('end', resolve);
attachmentAsStreamOrBuffer.stream.on('error', reject);
attachmentAsStreamOrBuffer.stream.pipe(res);
});
}
@GlobalScope('chatHub:message')
@Post('/conversations/send')
async sendMessage(

View File

@ -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<ChatModelsResponse['awsBedrock']> {
// 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<ChatModelsResponse['mistralCloud']> {
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<ChatModelsResponse['cohere']> {
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<ChatModelsResponse['n8n']> {
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<ChatHubConversationsResponse> {
const sessions = await this.sessionRepository.getManyByUserId(userId);
async getConversations(
userId: string,
limit: number,
cursor?: string,
): Promise<ChatHubConversationsResponse> {
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);
}
}

View File

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

Some files were not shown because too many files have changed in this diff Show More