mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-28 11:35:03 +02:00
Replaces the in-body progress bar with a determinate arc on the node's own border, plus a step count in the status-icon slot that gives way to the success checkmark on finish. The running child node name reuses the subtitle line, so nothing shifts as the child advances. The arc is drawn on its own layer rather than sharing the running halo's ring: compositing the two there made the shimmer's bright head vanish behind the solid arc and read as a flicker. It trickles between steps, bounded to half a step, so a slow child node doesn't read as a stalled bar, and stops short of a full turn so only completion closes the loop. The step count carries no denominator. Nodes reached is knowable, the total is only estimated, so the count is reported unclamped and the estimate is used just to scale the arc. That total now counts only nodes reachable from the sub-workflow's entry point (plus their AI/tool sub-nodes), which stops leftover trigger branches and disconnected islands from scaling the arc down. Co-Authored-By: Claude <noreply@anthropic.com>
197 lines
6.3 KiB
TypeScript
197 lines
6.3 KiB
TypeScript
import {
|
|
CliWorkflowOperationError,
|
|
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
|
isHitlToolType,
|
|
SubworkflowOperationError,
|
|
} from 'n8n-workflow';
|
|
import type { INode, INodeType, Workflow } from 'n8n-workflow';
|
|
|
|
import { STARTING_NODES } from '@/constants';
|
|
|
|
/**
|
|
* Returns if the given id is a valid workflow id
|
|
* id should be 16 characters but we allow <=21 to support longer IDs from December 2025
|
|
*/
|
|
export function isWorkflowIdValid(id: string | null | undefined): boolean {
|
|
return typeof id === 'string' && id.length > 0 && id.length <= 21;
|
|
}
|
|
|
|
/**
|
|
* Strips the "Tool"/"HitlTool" suffix from a tool-variant node type (e.g. `openAiTool`
|
|
* → `openAi`), yielding the base node type. Kept in sync with the editor-ui
|
|
* `stripToolSuffix` so backend and frontend agree on the lookup fallback.
|
|
*/
|
|
export function stripToolSuffix(nodeType: string): string {
|
|
return nodeType.replace(/HitlTool$/, '').replace(/Tool$/, '');
|
|
}
|
|
|
|
/**
|
|
* Whether the given resolved node (version) can back the synthetic tool name.
|
|
* HITL tools have no capability requirement; regular tools need the base node
|
|
* version to declare `usableAsTool`.
|
|
*/
|
|
export function satisfiesToolCapability(syntheticToolName: string, nodeType: INodeType): boolean {
|
|
return isHitlToolType(syntheticToolName) || !!nodeType.description.usableAsTool;
|
|
}
|
|
|
|
/**
|
|
* The node a sub-workflow execution starts from, or `undefined` when there is
|
|
* none. Non-throwing counterpart to `findSubworkflowStart`, for callers that
|
|
* only want to inspect the entry point.
|
|
*/
|
|
export function findSubworkflowStartOrUndefined(nodes: INode[]): INode | undefined {
|
|
return (
|
|
nodes.find((node) => node.type === EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE) ??
|
|
nodes.find((node) => STARTING_NODES.includes(node.type))
|
|
);
|
|
}
|
|
|
|
function findWorkflowStart(executionMode: 'integrated' | 'cli') {
|
|
return function (nodes: INode[]) {
|
|
const startNode = findSubworkflowStartOrUndefined(nodes);
|
|
|
|
if (startNode) return startNode;
|
|
|
|
const title = 'Missing node to start execution';
|
|
const description =
|
|
"Please make sure the workflow you're calling contains an Execute Workflow Trigger node";
|
|
|
|
if (executionMode === 'integrated') {
|
|
throw new SubworkflowOperationError(title, description);
|
|
}
|
|
|
|
throw new CliWorkflowOperationError(title, description);
|
|
};
|
|
}
|
|
|
|
export const findSubworkflowStart = findWorkflowStart('integrated');
|
|
|
|
export const findCliWorkflowStart = findWorkflowStart('cli');
|
|
|
|
export const toError = (maybeError: unknown) =>
|
|
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
|
maybeError instanceof Error ? maybeError : new Error(`${maybeError}`);
|
|
|
|
export const isIntegerString = (value: string) => /^\d+$/.test(value);
|
|
|
|
export function removeTrailingSlash(path: string) {
|
|
return path.endsWith('/') ? path.slice(0, -1) : path;
|
|
}
|
|
|
|
// return the difference between two arrays
|
|
export function rightDiff<T1, T2>(
|
|
[arr1, keyExtractor1]: [T1[], (item: T1) => string],
|
|
[arr2, keyExtractor2]: [T2[], (item: T2) => string],
|
|
): T2[] {
|
|
// create map { itemKey => true } for fast lookup for diff
|
|
const keyMap = arr1.reduce<{ [key: string]: true }>((map, item) => {
|
|
map[keyExtractor1(item)] = true;
|
|
return map;
|
|
}, {});
|
|
|
|
// diff against map
|
|
return arr2.reduce<T2[]>((acc, item) => {
|
|
if (!keyMap[keyExtractor2(item)]) {
|
|
acc.push(item);
|
|
}
|
|
return acc;
|
|
}, []);
|
|
}
|
|
|
|
/**
|
|
* Asserts that the passed in type is never.
|
|
* Can be used to make sure the type is exhausted
|
|
* in switch statements or if/else chains.
|
|
*/
|
|
export const assertNever = (_value: never) => {};
|
|
|
|
export const isPositiveInteger = (maybeInt: string) => /^[1-9]\d*$/.test(maybeInt);
|
|
|
|
/**
|
|
* Check if a execute method should be assigned to the node
|
|
*/
|
|
export const shouldAssignExecuteMethod = (nodeType: INodeType) => {
|
|
const isDeclarativeNode = nodeType?.description?.requestDefaults !== undefined;
|
|
|
|
return (
|
|
!nodeType.execute &&
|
|
!nodeType.supplyData &&
|
|
!nodeType.poll &&
|
|
!nodeType.trigger &&
|
|
(!nodeType.webhook || isDeclarativeNode) &&
|
|
(!nodeType.methods || isDeclarativeNode)
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Recursively gets all key paths of an object or array, filtered by the provided value filter.
|
|
* @param obj - The object or array to search.
|
|
* @param keys - The array to store matching keys.
|
|
* @param valueFilter - A function to filter values.
|
|
* @returns The array of matching key paths.
|
|
*/
|
|
export const getAllKeyPaths = (
|
|
obj: unknown,
|
|
currentPath = '',
|
|
paths: string[] = [],
|
|
valueFilter: (value: string) => boolean,
|
|
): string[] => {
|
|
if (Array.isArray(obj)) {
|
|
obj.forEach((item, index) =>
|
|
getAllKeyPaths(item, `${currentPath}[${index}]`, paths, valueFilter),
|
|
);
|
|
} else if (obj && typeof obj === 'object') {
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
const newPath = currentPath ? `${currentPath}.${key}` : key;
|
|
if (typeof value === 'string' && valueFilter(value)) {
|
|
paths.push(newPath);
|
|
} else {
|
|
getAllKeyPaths(value, newPath, paths, valueFilter);
|
|
}
|
|
}
|
|
}
|
|
return paths;
|
|
};
|
|
|
|
/**
|
|
* Sets Microsoft 365 observability environment variables to their default values if not already set.
|
|
* Sets ENABLE_OBSERVABILITY and ENABLE_A365_OBSERVABILITY_EXPORTER to 'true' if they are undefined or empty.
|
|
*/
|
|
export function setMicrosoftObservabilityDefaults(): void {
|
|
if (process.env.ENABLE_OBSERVABILITY === undefined || process.env.ENABLE_OBSERVABILITY === '') {
|
|
process.env.ENABLE_OBSERVABILITY = 'true';
|
|
}
|
|
if (
|
|
process.env.ENABLE_A365_OBSERVABILITY_EXPORTER === undefined ||
|
|
process.env.ENABLE_A365_OBSERVABILITY_EXPORTER === ''
|
|
) {
|
|
process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = 'true';
|
|
}
|
|
}
|
|
|
|
export function containsExpression(testString: string): boolean {
|
|
return /^=.*\{\{.+\}\}/.test(testString);
|
|
}
|
|
|
|
export function isObject(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
/**
|
|
* When N8N_EXPRESSION_ENGINE=vm, expressions run in an isolate that must be acquired
|
|
* for this workflow before any code resolves {{ }} in parameters or credentials.
|
|
*/
|
|
export async function withExpressionIsolate<T>(
|
|
workflow: Workflow,
|
|
fn: () => Promise<T>,
|
|
): Promise<T> {
|
|
// Release is not reference-counted: only release if this scope newly acquired
|
|
// the isolate, otherwise we'd return the outer caller's bridge to the pool mid-use.
|
|
const acquired = await workflow.expression.acquireIsolate();
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
if (acquired) await workflow.expression.releaseIsolate();
|
|
}
|
|
}
|