mirror of
https://github.com/n8n-io/n8n.git
synced 2025-11-20 17:46:34 +00:00
Merge branch 'master' into fix-ai-1668-thinking-model-error
This commit is contained in:
commit
3984bc3937
119
.github/scripts/determine-runners-tags.sh
vendored
119
.github/scripts/determine-runners-tags.sh
vendored
@ -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
171
.github/scripts/docker/docker-config.mjs
vendored
Normal 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
113
.github/scripts/docker/docker-tags.mjs
vendored
Normal 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;
|
||||
2
.github/workflows/ci-postgres-mysql.yml
vendored
2
.github/workflows/ci-postgres-mysql.yml
vendored
@ -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
|
||||
|
||||
47
.github/workflows/ci-pull-requests.yml
vendored
47
.github/workflows/ci-pull-requests.yml
vendored
@ -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
|
||||
|
||||
453
.github/workflows/docker-build-push.yml
vendored
453
.github/workflows/docker-build-push.yml
vendored
@ -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' ||
|
||||
|
||||
108
.github/workflows/e2e-tests-pr-comment.yml
vendored
108
.github/workflows/e2e-tests-pr-comment.yml
vendored
@ -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 }}
|
||||
42
.github/workflows/e2e-tests-pr.yml
vendored
42
.github/workflows/e2e-tests-pr.yml
vendored
@ -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
|
||||
66
.github/workflows/e2e-tests.yml
vendored
66
.github/workflows/e2e-tests.yml
vendored
@ -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
|
||||
@ -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 }}
|
||||
|
||||
@ -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:
|
||||
|
||||
40
.github/workflows/units-tests-reusable.yml
vendored
40
.github/workflows/units-tests-reusable.yml
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
# ==============================================================================
|
||||
|
||||
@ -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
|
||||
# ==============================================================================
|
||||
|
||||
@ -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
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -27,10 +27,13 @@ export {
|
||||
emptyChatModelsResponse,
|
||||
type ChatModelsRequest,
|
||||
type ChatModelsResponse,
|
||||
chatAttachmentSchema,
|
||||
type ChatAttachment,
|
||||
ChatHubSendMessageRequest,
|
||||
ChatHubRegenerateMessageRequest,
|
||||
ChatHubEditMessageRequest,
|
||||
ChatHubUpdateConversationRequest,
|
||||
ChatHubConversationsRequest,
|
||||
type ChatMessageId,
|
||||
type ChatSessionId,
|
||||
type ChatHubMessageDto,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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']);
|
||||
}
|
||||
}
|
||||
@ -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']);
|
||||
}
|
||||
}
|
||||
@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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', {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
};
|
||||
@ -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>;
|
||||
@ -0,0 +1,5 @@
|
||||
export {
|
||||
ContextEstablishmentHookMetadata,
|
||||
ContextEstablishmentHook,
|
||||
} from './context-establishment-hook-metadata';
|
||||
export type * from './context-establishment-hook';
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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' } },
|
||||
});
|
||||
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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', () => {
|
||||
87
packages/@n8n/utils/src/files/sanitize.ts
Normal file
87
packages/@n8n/utils/src/files/sanitize.ts
Normal 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;
|
||||
};
|
||||
@ -7,3 +7,4 @@ export * from './search/reRankSearchResults';
|
||||
export * from './search/sublimeSearch';
|
||||
export * from './sort/sortByProperty';
|
||||
export * from './string/truncate';
|
||||
export * from './files/sanitize';
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -65,6 +65,7 @@ describe('ActiveExecutions', () => {
|
||||
id: '123',
|
||||
name: 'Test workflow 1',
|
||||
active: false,
|
||||
activeVersionId: null,
|
||||
isArchived: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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`,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -1215,6 +1215,7 @@ describe('TelemetryEventRelay', () => {
|
||||
id: 'workflow123',
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
activeVersionId: 'some-version-id',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
|
||||
@ -62,6 +62,7 @@ describe('Execution Lifecycle Hooks', () => {
|
||||
id: workflowId,
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
activeVersionId: 'some-version-id',
|
||||
isArchived: false,
|
||||
connections: {},
|
||||
nodes: [
|
||||
|
||||
@ -38,6 +38,7 @@ export function prepareExecutionDataForDbUpdate(parameters: {
|
||||
'id',
|
||||
'name',
|
||||
'active',
|
||||
'activeVersionId',
|
||||
'isArchived',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
|
||||
@ -183,6 +183,7 @@ export class ExecutionService {
|
||||
const executionMode = 'retry';
|
||||
|
||||
execution.workflowData.active = false;
|
||||
execution.workflowData.activeVersionId = null;
|
||||
|
||||
// Start the workflow
|
||||
const data: IWorkflowExecutionDataProcess = {
|
||||
|
||||
15
packages/cli/src/executions/execution.utils.ts
Normal file
15
packages/cli/src/executions/execution.utils.ts
Normal 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;
|
||||
}
|
||||
@ -6,6 +6,7 @@ export class WorkflowSelect extends BaseSelect {
|
||||
'id', // always included downstream
|
||||
'name',
|
||||
'active',
|
||||
'activeVersionId',
|
||||
'tags',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
|
||||
@ -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: [
|
||||
{
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -32,6 +32,7 @@ export class ChatHubAgentService {
|
||||
},
|
||||
createdAt: agent.createdAt.toISOString(),
|
||||
updatedAt: agent.updatedAt.toISOString(),
|
||||
allowFileUploads: true,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
160
packages/cli/src/modules/chat-hub/chat-hub.attachment.service.ts
Normal file
160
packages/cli/src/modules/chat-hub/chat-hub.attachment.service.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 */
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user