feat: add n8n v2.0 external task runner support

upgrade n8n from 1.123.3 to 2.0.0-rc.4 with external task runners for
Code node execution. task runners handle javascript and python code
execution in isolated containers with configurable concurrency.

key changes:
- add n8n-runner service using n8nio/runners:2.0.0-rc.4
- configure runner auth token and broker communication
- add N8N_RUNNER_COUNT for scaling runner replicas
- move code node library config to runner container
- update binary data mode from filesystem to database
- add runner count prompt to installation wizard
This commit is contained in:
Yury Kossakovsky
2025-12-07 15:54:22 -07:00
parent 209c739e7f
commit 638c3d17a6
7 changed files with 160 additions and 53 deletions

View File

@@ -16,6 +16,7 @@ FLOWISE_PASSWORD=
N8N_ENCRYPTION_KEY=
N8N_USER_MANAGEMENT_JWT_SECRET=
N8N_RUNNERS_AUTH_TOKEN=
############
@@ -171,9 +172,20 @@ WEBUI_HOSTNAME=webui.yourdomain.com
RUN_N8N_IMPORT=
# n8n worker configuration
############
# [optional]
# n8n configuration
############
# Number of n8n worker replicas (for the n8n-worker service). Defaults to 1 if unset.
N8N_WORKER_COUNT=
# Number of n8n task runner replicas (for Code/Python node execution). Defaults to 1 if unset.
N8N_RUNNER_COUNT=
# Maximum number of concurrent Code node executions per task runner. Defaults to 5.
N8N_RUNNERS_MAX_CONCURRENCY=5
N8N_BLOCK_FILE_ACCESS_TO_N8N_FILES=true
EXECUTIONS_MODE=queue
N8N_LOG_LEVEL=info

View File

@@ -90,12 +90,20 @@ Follow this workflow when adding a new optional service (refer to `.cursor/rules
## Important Service Details
### n8n Configuration
### n8n Configuration (v2.0+)
- n8n runs in `EXECUTIONS_MODE=queue` with Redis as the queue backend
- Custom JavaScript libraries are pre-installed: `cheerio`, `axios`, `moment`, `lodash` (see `NODE_FUNCTION_ALLOW_EXTERNAL`)
- **Task runners**: n8n v2.0 uses external task runners for Code node execution (JavaScript and Python)
- Runner count controlled by `N8N_RUNNER_COUNT` env var (defaults to 1)
- Runner image `n8nio/runners` must match n8n version
- **Code node libraries**: Configured on the runner container (not n8n):
- `NODE_FUNCTION_ALLOW_EXTERNAL`: JS packages (`cheerio`, `axios`, `moment`, `lodash`)
- `NODE_FUNCTION_ALLOW_BUILTIN`: Node.js built-in modules (`*` = all)
- `N8N_RUNNERS_STDLIB_ALLOW`: Python stdlib modules
- `N8N_RUNNERS_EXTERNAL_ALLOW`: Python third-party packages
- Workflows can access the host filesystem via `/data/shared` (mapped to `./shared`)
- Worker count is controlled by `N8N_WORKER_COUNT` env var (defaults to 1)
- `N8N_BLOCK_ENV_ACCESS_IN_NODE=false` allows Code nodes to access environment variables
### Caddy Reverse Proxy
@@ -115,7 +123,7 @@ The `scripts/03_generate_secrets.sh` script:
### Service Profiles
Common profiles:
- `n8n`: n8n workflow automation (includes main app, worker, and import services)
- `n8n`: n8n workflow automation (includes main app, worker, runner, and import services)
- `flowise`: Flowise AI agent builder
- `monitoring`: Prometheus, Grafana, cAdvisor, node-exporter
- `langfuse`: Langfuse observability (includes ClickHouse, MinIO, worker, web)

View File

@@ -14,7 +14,7 @@ This installer helps you create your own powerful, private AI workshop. Imagine
This setup provides a comprehensive suite of cutting-edge services, all pre-configured to work together. Key advantages include:
- **Rich Toolset:** Get a curated collection of powerful open-source tools for AI development, automation, and monitoring, all in one place.
- **Scalable n8n Performance:** n8n runs in `queue` mode by default, leveraging Redis for task management and Postgres for data storage. You can dynamically specify the number of n8n workers during installation, allowing for robust parallel processing of your workflows to handle demanding loads.
- **Scalable n8n Performance:** n8n runs in `queue` mode by default, leveraging Redis for task management and Postgres for data storage. You can dynamically specify the number of n8n workers and task runners during installation, allowing for robust parallel processing of your workflows to handle demanding loads.
- **Full Control:** All of this is hosted by you, giving you full control over your data, operations, and how resources are allocated.
### What's Included
@@ -130,7 +130,8 @@ During the installation, the script will prompt you for:
3. An optional **OpenAI API key** (Not required. If provided, it can be used by Supabase AI features and Crawl4ai. Press Enter to skip).
4. Whether you want to **import ~300 ready-made n8n community workflows** (y/n, Optional. This can take 20-30 minutes, depending on your server and network speed).
5. The **number of n8n workers** you want to run (Required, e.g., 1, 2, 3, 4. This determines how many workflows can be processed in parallel. Defaults to 1 if not specified).
6. A **Service Selection Wizard** will then appear, allowing you to choose which of the available services (like Flowise, Supabase, Qdrant, Open WebUI, etc.) you want to deploy. Core services (Caddy, Postgres, Redis) will be set up to support your selections.
6. The **number of n8n task runners** you want to run (Required, e.g., 1, 2, 3. Task runners execute JavaScript and Python Code nodes. Defaults to 1 if not specified).
7. A **Service Selection Wizard** will then appear, allowing you to choose which of the available services (like Flowise, Supabase, Qdrant, Open WebUI, etc.) you want to deploy. Core services (Caddy, Postgres, Redis) will be set up to support your selections.
Upon successful completion, the script will display a summary report. This report contains the access URLs and credentials for the deployed services. **Save this information in a safe place!**
@@ -206,10 +207,11 @@ Cloudflare Tunnel provides zero-trust access to your services without exposing a
See the Cloudflare Tunnel guide: [cloudflare-instructions.md](cloudflare-instructions.md)
### Using Pre-installed Libraries in n8n's Custom JavaScript
### Using Libraries in n8n Code Nodes (v2.0+)
This setup pre-installs useful Node.js libraries for use in n8n's Code nodes, allowing you to write custom JavaScript snippets with enhanced capabilities:
n8n v2.0 uses external task runners to execute JavaScript and Python code in Code nodes. This setup pre-configures the following libraries:
**JavaScript libraries** (via `NODE_FUNCTION_ALLOW_EXTERNAL`):
- **`cheerio`**: For parsing and manipulating HTML/XML (e.g., web scraping).
- **`axios`**: A promise-based HTTP client for making requests to external APIs.
- **`moment`**: For parsing, validating, manipulating, and displaying dates/times.

View File

@@ -49,7 +49,8 @@ x-n8n: &service-n8n
LANGCHAIN_API_KEY: ${LANGCHAIN_API_KEY}
LANGCHAIN_ENDPOINT: ${LANGCHAIN_ENDPOINT}
LANGCHAIN_TRACING_V2: ${LANGCHAIN_TRACING_V2}
N8N_BINARY_DATA_MODE: filesystem
N8N_BINARY_DATA_MODE: database
N8N_BLOCK_ENV_ACCESS_IN_NODE: false
N8N_BLOCK_FILE_ACCESS_TO_N8N_FILES: ${N8N_BLOCK_FILE_ACCESS_TO_N8N_FILES:-true}
N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE: true
N8N_DIAGNOSTICS_ENABLED: false
@@ -61,7 +62,11 @@ x-n8n: &service-n8n
N8N_METRICS: true
N8N_PAYLOAD_SIZE_MAX: 256
N8N_PERSONALIZATION_ENABLED: false
N8N_RESTRICT_FILE_ACCESS_TO: /data/shared
N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN}
N8N_RUNNERS_BROKER_LISTEN_ADDRESS: 0.0.0.0
N8N_RUNNERS_ENABLED: true
N8N_RUNNERS_MODE: external
N8N_SMTP_HOST: ${N8N_SMTP_HOST:-}
N8N_SMTP_OAUTH_PRIVATE_KEY: ${N8N_SMTP_OAUTH_PRIVATE_KEY:-}
N8N_SMTP_OAUTH_SERVICE_CLIENT: ${N8N_SMTP_OAUTH_SERVICE_CLIENT:-}
@@ -74,8 +79,6 @@ x-n8n: &service-n8n
N8N_TRUST_PROXY: true
N8N_USER_MANAGEMENT_JWT_SECRET: ${N8N_USER_MANAGEMENT_JWT_SECRET}
NODE_ENV: production
NODE_FUNCTION_ALLOW_BUILTIN: "*"
NODE_FUNCTION_ALLOW_EXTERNAL: cheerio,axios,moment,lodash
QUEUE_BULL_REDIS_HOST: ${REDIS_HOST:-redis}
QUEUE_BULL_REDIS_PORT: ${REDIS_PORT:-6379}
QUEUE_HEALTH_CHECK_ACTIVE: true
@@ -103,6 +106,18 @@ x-init-ollama: &init-ollama
- "-c"
- "sleep 3; OLLAMA_HOST=ollama:11434 ollama pull qwen2.5:7b-instruct-q4_K_M; OLLAMA_HOST=ollama:11434 ollama pull nomic-embed-text"
x-n8n-runner: &service-n8n-runner
image: n8nio/runners:2.0.0-rc.4
environment: &service-n8n-runner-env
N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN}
N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT: 15
N8N_RUNNERS_EXTERNAL_ALLOW: "*"
N8N_RUNNERS_MAX_CONCURRENCY: ${N8N_RUNNERS_MAX_CONCURRENCY:-5}
N8N_RUNNERS_STDLIB_ALLOW: "*"
N8N_RUNNERS_TASK_BROKER_URI: http://n8n:5679
NODE_FUNCTION_ALLOW_BUILTIN: "*"
NODE_FUNCTION_ALLOW_EXTERNAL: cheerio,axios,moment,lodash
services:
flowise:
image: flowiseai/flowise
@@ -178,6 +193,23 @@ services:
deploy:
replicas: ${N8N_WORKER_COUNT:-1}
n8n-runner:
<<: *service-n8n-runner
profiles: ["n8n"]
restart: unless-stopped
entrypoint: ["/bin/sh", "-c", "/usr/local/bin/task-runner-launcher javascript python"]
depends_on:
n8n:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:5680/healthz || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
deploy:
replicas: ${N8N_RUNNER_COUNT:-1}
qdrant:
image: qdrant/qdrant
container_name: qdrant

View File

@@ -1,5 +1,5 @@
FROM n8nio/n8n:1.123.4
FROM n8nio/n8n:2.0.0-rc.4
USER root
RUN apk add --no-cache ffmpeg
USER node
USER node

View File

@@ -21,53 +21,48 @@ DOMAIN_PLACEHOLDER="yourdomain.com"
# Variables to generate: varName="type:length"
# Types: password (alphanum), secret (base64), hex, base64, alphanum
declare -A VARS_TO_GENERATE=(
["FLOWISE_PASSWORD"]="password:32"
["N8N_ENCRYPTION_KEY"]="secret:64" # base64 encoded, 48 bytes -> 64 chars
["N8N_USER_MANAGEMENT_JWT_SECRET"]="secret:64" # base64 encoded, 48 bytes -> 64 chars
["POSTGRES_PASSWORD"]="password:32"
["POSTGRES_NON_ROOT_PASSWORD"]="password:32"
["JWT_SECRET"]="base64:64" # 48 bytes -> 64 chars
["DASHBOARD_PASSWORD"]="password:32" # Supabase Dashboard
["CLICKHOUSE_PASSWORD"]="password:32"
["MINIO_ROOT_PASSWORD"]="password:32"
["LANGFUSE_SALT"]="secret:64" # base64 encoded, 48 bytes -> 64 chars
["NEXTAUTH_SECRET"]="secret:64" # base64 encoded, 48 bytes -> 64 chars
["COMFYUI_PASSWORD"]="password:32" # Added ComfyUI basic auth password
["DASHBOARD_PASSWORD"]="password:32" # Supabase Dashboard
["DIFY_SECRET_KEY"]="secret:64" # Dify application secret key (maps to SECRET_KEY in Dify)
["DOCLING_PASSWORD"]="password:32"
["ENCRYPTION_KEY"]="hex:64" # Langfuse Encryption Key (32 bytes -> 64 hex chars)
["FLOWISE_PASSWORD"]="password:32"
["GRAFANA_ADMIN_PASSWORD"]="password:32"
# From MD file (ensure they are in template if needed)
["SECRET_KEY_BASE"]="base64:64" # 48 bytes -> 64 chars
["VAULT_ENC_KEY"]="alphanum:32"
["LOGFLARE_PRIVATE_ACCESS_TOKEN"]="fixed:not-in-use" # For supabase-vector, can't be empty
["LOGFLARE_PUBLIC_ACCESS_TOKEN"]="fixed:not-in-use" # For supabase-vector, can't be empty
["PROMETHEUS_PASSWORD"]="password:32" # Added Prometheus password
["SEARXNG_PASSWORD"]="password:32" # Added SearXNG admin password
["LETTA_SERVER_PASSWORD"]="password:32" # Added Letta server password
["LANGFUSE_INIT_USER_PASSWORD"]="password:32"
["JWT_SECRET"]="base64:64" # 48 bytes -> 64 chars
["LANGFUSE_INIT_PROJECT_PUBLIC_KEY"]="langfuse_pk:32"
["LANGFUSE_INIT_PROJECT_SECRET_KEY"]="langfuse_sk:32"
["WEAVIATE_API_KEY"]="secret:48" # API Key for Weaviate service (36 bytes -> 48 chars base64)
["QDRANT_API_KEY"]="secret:48" # API Key for Qdrant service
["LANGFUSE_INIT_USER_PASSWORD"]="password:32"
["LANGFUSE_SALT"]="secret:64" # base64 encoded, 48 bytes -> 64 chars
["LETTA_SERVER_PASSWORD"]="password:32" # Added Letta server password
["LIGHTRAG_API_KEY"]="secret:48"
["LIGHTRAG_PASSWORD"]="password:32"
["LOGFLARE_PRIVATE_ACCESS_TOKEN"]="fixed:not-in-use" # For supabase-vector, can't be empty
["LOGFLARE_PUBLIC_ACCESS_TOKEN"]="fixed:not-in-use" # For supabase-vector, can't be empty
["LT_PASSWORD"]="password:32" # Added LibreTranslate basic auth password
["MINIO_ROOT_PASSWORD"]="password:32"
["N8N_ENCRYPTION_KEY"]="secret:64" # base64 encoded, 48 bytes -> 64 chars
["N8N_RUNNERS_AUTH_TOKEN"]="secret:64" # Task runner auth token for n8n v2.0
["N8N_USER_MANAGEMENT_JWT_SECRET"]="secret:64" # base64 encoded, 48 bytes -> 64 chars
["NEO4J_AUTH_PASSWORD"]="password:32" # Added Neo4j password
["NEO4J_AUTH_USERNAME"]="fixed:neo4j" # Added Neo4j username
# Dify environment variables
["DIFY_SECRET_KEY"]="secret:64" # Dify application secret key (maps to SECRET_KEY in Dify)
["COMFYUI_PASSWORD"]="password:32" # Added ComfyUI basic auth password
["RAGAPP_PASSWORD"]="password:32" # Added RAGApp basic auth password
["NEXTAUTH_SECRET"]="secret:64" # base64 encoded, 48 bytes -> 64 chars
["PADDLEOCR_PASSWORD"]="password:32" # Added PaddleOCR basic auth password
["LT_PASSWORD"]="password:32" # Added LibreTranslate basic auth password
# WAHA (WhatsApp HTTP API)
["WAHA_DASHBOARD_PASSWORD"]="password:32"
["WHATSAPP_SWAGGER_PASSWORD"]="password:32"
# RAGFlow internal credentials
["RAGFLOW_MYSQL_ROOT_PASSWORD"]="password:32"
["RAGFLOW_MINIO_ROOT_PASSWORD"]="password:32"
["RAGFLOW_REDIS_PASSWORD"]="password:32"
["POSTGRES_NON_ROOT_PASSWORD"]="password:32"
["POSTGRES_PASSWORD"]="password:32"
["PROMETHEUS_PASSWORD"]="password:32" # Added Prometheus password
["QDRANT_API_KEY"]="secret:48" # API Key for Qdrant service
["RAGAPP_PASSWORD"]="password:32" # Added RAGApp basic auth password
["RAGFLOW_ELASTICSEARCH_PASSWORD"]="password:32"
# LightRAG credentials
["LIGHTRAG_PASSWORD"]="password:32"
["LIGHTRAG_API_KEY"]="secret:48"
# Docling credentials
["DOCLING_PASSWORD"]="password:32"
["RAGFLOW_MINIO_ROOT_PASSWORD"]="password:32"
["RAGFLOW_MYSQL_ROOT_PASSWORD"]="password:32"
["RAGFLOW_REDIS_PASSWORD"]="password:32"
["SEARXNG_PASSWORD"]="password:32" # Added SearXNG admin password
["SECRET_KEY_BASE"]="base64:64" # 48 bytes -> 64 chars
["VAULT_ENC_KEY"]="alphanum:32"
["WAHA_DASHBOARD_PASSWORD"]="password:32"
["WEAVIATE_API_KEY"]="secret:48" # API Key for Weaviate service (36 bytes -> 48 chars base64)
["WHATSAPP_SWAGGER_PASSWORD"]="password:32"
)
# Initialize existing_env_vars and attempt to read .env if it exists
@@ -295,6 +290,7 @@ found_vars["SEARXNG_USERNAME"]=0
found_vars["OPENAI_API_KEY"]=0
found_vars["LANGFUSE_INIT_USER_EMAIL"]=0
found_vars["N8N_WORKER_COUNT"]=0
found_vars["N8N_RUNNER_COUNT"]=0
found_vars["WEAVIATE_USERNAME"]=0
found_vars["NEO4J_AUTH_USERNAME"]=0
found_vars["COMFYUI_USERNAME"]=0
@@ -351,7 +347,7 @@ while IFS= read -r line || [[ -n "$line" ]]; do
# This 'else' block is for lines from template not covered by existing values or VARS_TO_GENERATE.
# Check if it is one of the user input vars - these are handled by found_vars later if not in template.
is_user_input_var=0 # Reset for each line
user_input_vars=("FLOWISE_USERNAME" "DASHBOARD_USERNAME" "LETSENCRYPT_EMAIL" "RUN_N8N_IMPORT" "PROMETHEUS_USERNAME" "SEARXNG_USERNAME" "OPENAI_API_KEY" "LANGFUSE_INIT_USER_EMAIL" "N8N_WORKER_COUNT" "WEAVIATE_USERNAME" "NEO4J_AUTH_USERNAME" "COMFYUI_USERNAME" "RAGAPP_USERNAME" "PADDLEOCR_USERNAME" "LT_USERNAME" "LIGHTRAG_USERNAME" "WAHA_DASHBOARD_USERNAME" "WHATSAPP_SWAGGER_USERNAME")
user_input_vars=("FLOWISE_USERNAME" "DASHBOARD_USERNAME" "LETSENCRYPT_EMAIL" "RUN_N8N_IMPORT" "PROMETHEUS_USERNAME" "SEARXNG_USERNAME" "OPENAI_API_KEY" "LANGFUSE_INIT_USER_EMAIL" "N8N_WORKER_COUNT" "N8N_RUNNER_COUNT" "WEAVIATE_USERNAME" "NEO4J_AUTH_USERNAME" "COMFYUI_USERNAME" "RAGAPP_USERNAME" "PADDLEOCR_USERNAME" "LT_USERNAME" "LIGHTRAG_USERNAME" "WAHA_DASHBOARD_USERNAME" "WHATSAPP_SWAGGER_USERNAME")
for uivar in "${user_input_vars[@]}"; do
if [[ "$varName" == "$uivar" ]]; then
is_user_input_var=1
@@ -433,7 +429,7 @@ if [[ -z "${generated_values[SERVICE_ROLE_KEY]}" ]]; then
fi
# Add any custom variables that weren't found in the template
for var in "FLOWISE_USERNAME" "DASHBOARD_USERNAME" "LETSENCRYPT_EMAIL" "RUN_N8N_IMPORT" "OPENAI_API_KEY" "PROMETHEUS_USERNAME" "SEARXNG_USERNAME" "LANGFUSE_INIT_USER_EMAIL" "N8N_WORKER_COUNT" "WEAVIATE_USERNAME" "NEO4J_AUTH_USERNAME" "COMFYUI_USERNAME" "RAGAPP_USERNAME" "PADDLEOCR_USERNAME" "LT_USERNAME" "LIGHTRAG_USERNAME" "WAHA_DASHBOARD_USERNAME" "WHATSAPP_SWAGGER_USERNAME" "DOCLING_USERNAME"; do
for var in "FLOWISE_USERNAME" "DASHBOARD_USERNAME" "LETSENCRYPT_EMAIL" "RUN_N8N_IMPORT" "OPENAI_API_KEY" "PROMETHEUS_USERNAME" "SEARXNG_USERNAME" "LANGFUSE_INIT_USER_EMAIL" "N8N_WORKER_COUNT" "N8N_RUNNER_COUNT" "WEAVIATE_USERNAME" "NEO4J_AUTH_USERNAME" "COMFYUI_USERNAME" "RAGAPP_USERNAME" "PADDLEOCR_USERNAME" "LT_USERNAME" "LIGHTRAG_USERNAME" "WAHA_DASHBOARD_USERNAME" "WHATSAPP_SWAGGER_USERNAME" "DOCLING_USERNAME"; do
if [[ ${found_vars["$var"]} -eq 0 && -v generated_values["$var"] ]]; then
# Before appending, check if it's already in TMP_ENV_FILE to avoid duplicates
if ! grep -q -E "^${var}=" "$TMP_ENV_FILE"; then

View File

@@ -127,6 +127,63 @@ N8N_WORKER_COUNT="${N8N_WORKER_COUNT:-1}"
write_env_var "N8N_WORKER_COUNT" "$N8N_WORKER_COUNT"
# ----------------------------------------------------------------
# Prompt for number of n8n runners (for Code/Python node execution)
# ----------------------------------------------------------------
echo "" # Add a newline for better formatting
log_info "Configuring n8n runner count..."
EXISTING_N8N_RUNNER_COUNT="$(read_env_var N8N_RUNNER_COUNT)"
require_whiptail
if [[ -n "$EXISTING_N8N_RUNNER_COUNT" ]]; then
N8N_RUNNER_COUNT_CURRENT="$EXISTING_N8N_RUNNER_COUNT"
N8N_RUNNER_COUNT_INPUT_RAW=$(wt_input "n8n Runners (instances)" "Enter new number of n8n runners, or leave as current ($N8N_RUNNER_COUNT_CURRENT)." "") || true
if [[ -z "$N8N_RUNNER_COUNT_INPUT_RAW" ]]; then
N8N_RUNNER_COUNT="$N8N_RUNNER_COUNT_CURRENT"
else
if [[ "$N8N_RUNNER_COUNT_INPUT_RAW" =~ ^0*[1-9][0-9]*$ ]]; then
N8N_RUNNER_COUNT_TEMP="$((10#$N8N_RUNNER_COUNT_INPUT_RAW))"
if [[ "$N8N_RUNNER_COUNT_TEMP" -ge 1 ]]; then
if wt_yesno "Confirm Runners" "Update n8n runners to $N8N_RUNNER_COUNT_TEMP?" "yes"; then
N8N_RUNNER_COUNT="$N8N_RUNNER_COUNT_TEMP"
else
N8N_RUNNER_COUNT="$N8N_RUNNER_COUNT_CURRENT"
log_info "Change declined. Keeping N8N_RUNNER_COUNT at $N8N_RUNNER_COUNT."
fi
else
log_warning "Invalid input '$N8N_RUNNER_COUNT_INPUT_RAW'. Number must be positive. Keeping $N8N_RUNNER_COUNT_CURRENT."
N8N_RUNNER_COUNT="$N8N_RUNNER_COUNT_CURRENT"
fi
else
log_warning "Invalid input '$N8N_RUNNER_COUNT_INPUT_RAW'. Please enter a positive integer. Keeping $N8N_RUNNER_COUNT_CURRENT."
N8N_RUNNER_COUNT="$N8N_RUNNER_COUNT_CURRENT"
fi
fi
else
while true; do
N8N_RUNNER_COUNT_INPUT_RAW=$(wt_input "n8n Runners" "Enter number of n8n runners for Code/Python nodes (default 1)." "1") || true
N8N_RUNNER_COUNT_CANDIDATE="${N8N_RUNNER_COUNT_INPUT_RAW:-1}"
if [[ "$N8N_RUNNER_COUNT_CANDIDATE" =~ ^0*[1-9][0-9]*$ ]]; then
N8N_RUNNER_COUNT_VALIDATED="$((10#$N8N_RUNNER_COUNT_CANDIDATE))"
if [[ "$N8N_RUNNER_COUNT_VALIDATED" -ge 1 ]]; then
if wt_yesno "Confirm Runners" "Run $N8N_RUNNER_COUNT_VALIDATED n8n runner(s)?" "yes"; then
N8N_RUNNER_COUNT="$N8N_RUNNER_COUNT_VALIDATED"
break
fi
else
log_error "Number of runners must be a positive integer." >&2
fi
else
log_error "Invalid input '$N8N_RUNNER_COUNT_CANDIDATE'. Please enter a positive integer (e.g., 1, 2)." >&2
fi
done
fi
# Ensure N8N_RUNNER_COUNT is definitely set (should be by logic above)
N8N_RUNNER_COUNT="${N8N_RUNNER_COUNT:-1}"
# Persist N8N_RUNNER_COUNT to .env
write_env_var "N8N_RUNNER_COUNT" "$N8N_RUNNER_COUNT"
# ----------------------------------------------------------------
# Cloudflare Tunnel Token (if cloudflare-tunnel profile is active)
# ----------------------------------------------------------------