mirror of
https://github.com/twentyhq/twenty.git
synced 2026-06-12 09:57:03 -04:00
feat(workflow) - Add validation layer (#21422)
Add workflow validation framework and consolidate output schema types/search logic into twenty-shared This PR introduces a comprehensive workflow validation system that catches configuration errors at build-time, and consolidates the fragmented output-schema type definitions and variable-search logic from the front-end into twenty-shared **Workflow validation** — A new system that checks workflows for errors before activation: graph connectivity (unreachable steps, dangling references), step parameter schemas (via Zod), variable references (typos, wrong step order), and workspace metadata (non-existent objects). Returns structured errors/warnings with "did you mean?" suggestions. Runs automatically after create_complete_workflow and update_workflow_version_step, and is also available as a standalone validate_workflow tool. **Output schema consolidation** — Moves all output schema types and the variable-search logic from scattered front-end files into twenty-shared, replacing ~800 lines of duplicated per-schema-type code with a single unified searchVariableInOutputSchema dispatcher. To do : - validation on CODE and AGENT step --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
import { computeCursorArgFilter } from '@/object-record/graphql/utils/computeCursorArgFilter';
|
||||
import { type RecordGqlOperationOrderBy } from 'twenty-shared/types';
|
||||
|
||||
describe('computeCursorArgFilter', () => {
|
||||
it('should append an id tie-breaker when ordering does not include id', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [{ createdAt: 'AscNullsFirst' }];
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { createdAt: '2024-01-01', id: 'record-1' },
|
||||
isForwardPagination: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
or: [
|
||||
{ createdAt: { gt: '2024-01-01' } },
|
||||
{
|
||||
and: [
|
||||
{ createdAt: { eq: '2024-01-01' } },
|
||||
{ id: { gt: 'record-1' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not append an id field when it is already part of the ordering', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [{ id: 'AscNullsFirst' }];
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { id: 'record-1' },
|
||||
isForwardPagination: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ or: [{ id: { gt: 'record-1' } }] });
|
||||
});
|
||||
|
||||
it('should use lt operator for ascending order with backward pagination', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [{ id: 'AscNullsLast' }];
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { id: 'record-1' },
|
||||
isForwardPagination: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ or: [{ id: { lt: 'record-1' } }] });
|
||||
});
|
||||
|
||||
it('should use lt operator for descending order with forward pagination', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [{ id: 'DescNullsFirst' }];
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { id: 'record-1' },
|
||||
isForwardPagination: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ or: [{ id: { lt: 'record-1' } }] });
|
||||
});
|
||||
|
||||
it('should use gt operator for descending order with backward pagination', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [{ id: 'DescNullsLast' }];
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { id: 'record-1' },
|
||||
isForwardPagination: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ or: [{ id: { gt: 'record-1' } }] });
|
||||
});
|
||||
|
||||
it('should resolve nested composite sub-fields and read their cursor value', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [
|
||||
{ name: { firstName: 'AscNullsFirst' } },
|
||||
];
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { name: { firstName: 'John' }, id: 'record-1' },
|
||||
isForwardPagination: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
or: [
|
||||
{ name: { firstName: { gt: 'John' } } },
|
||||
{
|
||||
and: [
|
||||
{ name: { firstName: { eq: 'John' } } },
|
||||
{ id: { gt: 'record-1' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall back to undefined cursor value for missing composite parent', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [
|
||||
{ name: { firstName: 'AscNullsFirst' } },
|
||||
];
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { id: 'record-1' },
|
||||
isForwardPagination: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
or: [
|
||||
{ name: { firstName: { gt: undefined } } },
|
||||
{
|
||||
and: [
|
||||
{ name: { firstName: { eq: undefined } } },
|
||||
{ id: { gt: 'record-1' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore nested values that are not order-by directions', () => {
|
||||
const orderBy = [
|
||||
{ name: { firstName: 'AscNullsFirst', metadata: 'not-a-direction' } },
|
||||
] as unknown as RecordGqlOperationOrderBy;
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { name: { firstName: 'John' }, id: 'record-1' },
|
||||
isForwardPagination: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
or: [
|
||||
{ name: { firstName: { gt: 'John' } } },
|
||||
{
|
||||
and: [
|
||||
{ name: { firstName: { eq: 'John' } } },
|
||||
{ id: { gt: 'record-1' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should build cumulative equality prefixes across multiple fields', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [
|
||||
{ score: 'DescNullsLast' },
|
||||
{ id: 'AscNullsFirst' },
|
||||
];
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { score: 42, id: 'record-1' },
|
||||
isForwardPagination: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
or: [
|
||||
{ score: { lt: 42 } },
|
||||
{
|
||||
and: [{ score: { eq: 42 } }, { id: { gt: 'record-1' } }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall back to the id tie-breaker when there are no order-by fields', () => {
|
||||
const orderBy = [{}] as unknown as RecordGqlOperationOrderBy;
|
||||
|
||||
const result = computeCursorArgFilter({
|
||||
orderBy,
|
||||
cursorRecordValues: { id: 'record-1' },
|
||||
isForwardPagination: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ or: [{ id: { gt: 'record-1' } }] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { extractOrderByFieldNames } from '@/object-record/graphql/utils/extractOrderByFieldNames';
|
||||
import { type RecordGqlOperationOrderBy } from 'twenty-shared/types';
|
||||
|
||||
describe('extractOrderByFieldNames', () => {
|
||||
it('should always include the id field', () => {
|
||||
expect(extractOrderByFieldNames([])).toEqual({ id: true });
|
||||
});
|
||||
|
||||
it('should extract top-level field names', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [
|
||||
{ createdAt: 'AscNullsFirst' },
|
||||
{ name: 'DescNullsLast' },
|
||||
];
|
||||
|
||||
expect(extractOrderByFieldNames(orderBy)).toEqual({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
name: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract nested composite sub-field names', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [
|
||||
{ name: { firstName: 'AscNullsFirst', lastName: 'DescNullsLast' } },
|
||||
];
|
||||
|
||||
expect(extractOrderByFieldNames(orderBy)).toEqual({
|
||||
id: true,
|
||||
name: { firstName: true, lastName: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore nested values that are not directions', () => {
|
||||
const orderBy = [
|
||||
{ name: { firstName: 'AscNullsFirst', meta: 'Unknown' } },
|
||||
] as unknown as RecordGqlOperationOrderBy;
|
||||
|
||||
expect(extractOrderByFieldNames(orderBy)).toEqual({
|
||||
id: true,
|
||||
name: { firstName: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { isOrderByDirection } from '@/object-record/graphql/utils/isOrderByDirection';
|
||||
|
||||
describe('isOrderByDirection', () => {
|
||||
it('should return true for valid order by directions', () => {
|
||||
expect(isOrderByDirection('AscNullsFirst')).toBe(true);
|
||||
expect(isOrderByDirection('AscNullsLast')).toBe(true);
|
||||
expect(isOrderByDirection('DescNullsFirst')).toBe(true);
|
||||
expect(isOrderByDirection('DescNullsLast')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for unknown strings', () => {
|
||||
expect(isOrderByDirection('Asc')).toBe(false);
|
||||
expect(isOrderByDirection('random')).toBe(false);
|
||||
expect(isOrderByDirection('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-string values', () => {
|
||||
expect(isOrderByDirection(undefined)).toBe(false);
|
||||
expect(isOrderByDirection(null)).toBe(false);
|
||||
expect(isOrderByDirection(42)).toBe(false);
|
||||
expect(isOrderByDirection({ foo: 'AscNullsFirst' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { reverseOrderBy } from '@/object-record/graphql/utils/reverseOrderBy';
|
||||
import { type RecordGqlOperationOrderBy } from 'twenty-shared/types';
|
||||
|
||||
describe('reverseOrderBy', () => {
|
||||
it('should reverse top-level directions', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [
|
||||
{ createdAt: 'AscNullsFirst' },
|
||||
{ name: 'DescNullsLast' },
|
||||
];
|
||||
|
||||
expect(reverseOrderBy(orderBy)).toEqual([
|
||||
{ createdAt: 'DescNullsLast' },
|
||||
{ name: 'AscNullsFirst' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should reverse all direction variants', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [
|
||||
{ a: 'AscNullsFirst' },
|
||||
{ b: 'AscNullsLast' },
|
||||
{ c: 'DescNullsFirst' },
|
||||
{ d: 'DescNullsLast' },
|
||||
];
|
||||
|
||||
expect(reverseOrderBy(orderBy)).toEqual([
|
||||
{ a: 'DescNullsLast' },
|
||||
{ b: 'DescNullsFirst' },
|
||||
{ c: 'AscNullsLast' },
|
||||
{ d: 'AscNullsFirst' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should reverse nested composite field directions', () => {
|
||||
const orderBy: RecordGqlOperationOrderBy = [
|
||||
{ name: { firstName: 'AscNullsFirst', lastName: 'DescNullsLast' } },
|
||||
];
|
||||
|
||||
expect(reverseOrderBy(orderBy)).toEqual([
|
||||
{ name: { firstName: 'DescNullsLast', lastName: 'AscNullsFirst' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should leave unknown values untouched', () => {
|
||||
const orderBy = [
|
||||
{ name: 'Unknown' },
|
||||
] as unknown as RecordGqlOperationOrderBy;
|
||||
|
||||
expect(reverseOrderBy(orderBy)).toEqual([{ name: 'Unknown' }]);
|
||||
});
|
||||
});
|
||||
@@ -4,15 +4,10 @@ import { useWorkflowVersionIdOrThrow } from '@/workflow/hooks/useWorkflowVersion
|
||||
import { stepsOutputSchemaFamilySelector } from '@/workflow/states/selectors/stepsOutputSchemaFamilySelector';
|
||||
import { searchVariableThroughOutputSchemaV2 } from '@/workflow/workflow-variables/utils/searchVariableThroughOutputSchemaV2';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
|
||||
|
||||
export type VariableSearchResult = {
|
||||
variableLabel: string | undefined;
|
||||
variablePathLabel: string | undefined;
|
||||
variableType?: string;
|
||||
fieldMetadataId?: string;
|
||||
compositeFieldSubFieldName?: string;
|
||||
};
|
||||
import {
|
||||
TRIGGER_STEP_ID,
|
||||
type VariableSearchResult,
|
||||
} from 'twenty-shared/workflow';
|
||||
|
||||
export const useSearchVariable = ({
|
||||
stepId,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { type StepOutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2';
|
||||
import { searchVariableThroughOutputSchemaV2 } from '@/workflow/workflow-variables/utils/searchVariableThroughOutputSchemaV2';
|
||||
|
||||
describe('searchVariableThroughOutputSchemaV2', () => {
|
||||
const stepOutputSchema: StepOutputSchemaV2 = {
|
||||
id: 'step-1',
|
||||
name: 'HTTP Request',
|
||||
type: 'CODE',
|
||||
outputSchema: {
|
||||
message: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'Message',
|
||||
value: 'Hello World',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('should resolve a variable through the shared dispatcher', () => {
|
||||
const result = searchVariableThroughOutputSchemaV2({
|
||||
stepOutputSchema,
|
||||
stepType: 'CODE',
|
||||
rawVariableName: '{{step-1.message}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Message',
|
||||
variablePathLabel: 'HTTP Request > Message',
|
||||
variableType: 'string',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an empty result for an unknown variable path', () => {
|
||||
const result = searchVariableThroughOutputSchemaV2({
|
||||
stepOutputSchema,
|
||||
stepType: 'CODE',
|
||||
rawVariableName: '{{step-1.unknownField}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result.variableLabel).toBeUndefined();
|
||||
expect(result.variablePathLabel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
parseVariablePath,
|
||||
type BaseOutputSchemaV2,
|
||||
} from 'twenty-shared/workflow';
|
||||
|
||||
/**
|
||||
* Parses a variable name to extract its components
|
||||
* Example: "{{step1.field.value}}" -> { stepId: "step1", pathSegments: ["field"], targetFieldName: "value" }
|
||||
*/
|
||||
const parseVariableName = (rawVariableName: string) => {
|
||||
const variableWithoutBrackets = rawVariableName.replace(
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
(_, variableName) => variableName,
|
||||
);
|
||||
|
||||
const parts = parseVariablePath(variableWithoutBrackets);
|
||||
const stepId = parts.at(0);
|
||||
|
||||
return {
|
||||
stepId,
|
||||
targetFieldName: parts.at(-1),
|
||||
pathSegments: parts.slice(1, -1),
|
||||
};
|
||||
};
|
||||
|
||||
const navigateToTargetField = (
|
||||
startingSchema: BaseOutputSchemaV2,
|
||||
pathSegments: string[],
|
||||
): { schema: BaseOutputSchemaV2; pathLabels: string[] } | null => {
|
||||
let currentSchema: BaseOutputSchemaV2 = startingSchema;
|
||||
const pathLabels: string[] = [];
|
||||
|
||||
for (const pathSegment of pathSegments) {
|
||||
const field = currentSchema[pathSegment];
|
||||
|
||||
if (!isDefined(field) || field.isLeaf === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
pathLabels.push(field.label);
|
||||
currentSchema = field.value;
|
||||
}
|
||||
|
||||
return { schema: currentSchema, pathLabels };
|
||||
};
|
||||
|
||||
const buildVariableResult = (
|
||||
stepName: string,
|
||||
pathLabels: string[],
|
||||
targetSchema: BaseOutputSchemaV2,
|
||||
targetFieldName: string,
|
||||
): VariableSearchResult => {
|
||||
const targetField = targetSchema[targetFieldName];
|
||||
|
||||
if (!isDefined(targetField)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Build the full path: stepName > field1 > field2 > targetField
|
||||
const fullPathSegments = [stepName, ...pathLabels, targetField.label];
|
||||
const variablePathLabel = fullPathSegments.join(' > ');
|
||||
|
||||
return {
|
||||
variableLabel: targetField.label,
|
||||
variablePathLabel,
|
||||
variableType: targetField.type,
|
||||
};
|
||||
};
|
||||
|
||||
export const searchBaseOutputSchema = ({
|
||||
stepName,
|
||||
baseOutputSchema,
|
||||
path,
|
||||
selectedField,
|
||||
}: {
|
||||
stepName: string;
|
||||
baseOutputSchema: BaseOutputSchemaV2;
|
||||
path: string[];
|
||||
selectedField: string;
|
||||
}): VariableSearchResult => {
|
||||
const navigationResult = navigateToTargetField(baseOutputSchema, path);
|
||||
|
||||
if (!navigationResult) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
variableType: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return buildVariableResult(
|
||||
stepName,
|
||||
navigationResult.pathLabels,
|
||||
navigationResult.schema,
|
||||
selectedField,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Searches for a variable within a base output schema and returns its metadata
|
||||
*
|
||||
* @param stepName - Display name of the workflow step
|
||||
* @param baseOutputSchema - The base schema to search within
|
||||
* @param rawVariableName - Variable name like "{{step1.fieldName}}" or "step1.object.nested.value"
|
||||
* @param isFullRecord - Whether to return info for the entire record vs specific field (not used for base schema)
|
||||
* @returns Variable metadata including labels, types, and field information
|
||||
*/
|
||||
export const searchVariableThroughBaseOutputSchema = ({
|
||||
stepName,
|
||||
baseOutputSchema,
|
||||
rawVariableName,
|
||||
}: {
|
||||
stepName: string;
|
||||
baseOutputSchema: BaseOutputSchemaV2;
|
||||
rawVariableName: string;
|
||||
}): VariableSearchResult => {
|
||||
if (!isDefined(baseOutputSchema)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const { stepId, pathSegments, targetFieldName } =
|
||||
parseVariableName(rawVariableName);
|
||||
|
||||
if (!isDefined(stepId) || !isDefined(targetFieldName)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return searchBaseOutputSchema({
|
||||
stepName,
|
||||
baseOutputSchema,
|
||||
path: pathSegments,
|
||||
selectedField: targetFieldName,
|
||||
});
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
|
||||
import type { CodeOutputSchema } from '@/workflow/workflow-variables/types/CodeOutputSchema';
|
||||
import { searchVariableThroughBaseOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const isLinkOutputSchema = (
|
||||
codeOutputSchema: CodeOutputSchema,
|
||||
): codeOutputSchema is { link: any; _outputSchemaType: 'LINK' } => {
|
||||
return (
|
||||
isDefined(codeOutputSchema) && codeOutputSchema._outputSchemaType === 'LINK'
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Searches for a variable within a code output schema and returns its metadata
|
||||
*
|
||||
* @param stepName - Display name of the workflow step
|
||||
* @param codeOutputSchema - The code schema to search within
|
||||
* @param rawVariableName - Variable name like "{{step1.fieldName}}" or "step1.link"
|
||||
* @param isFullRecord - Whether to return info for the entire record vs specific field
|
||||
* @returns Variable metadata including labels, types, and field information
|
||||
*/
|
||||
export const searchVariableThroughCodeOutputSchema = ({
|
||||
stepName,
|
||||
codeOutputSchema,
|
||||
rawVariableName,
|
||||
}: {
|
||||
stepName: string;
|
||||
codeOutputSchema: CodeOutputSchema;
|
||||
rawVariableName: string;
|
||||
}): VariableSearchResult => {
|
||||
if (!isDefined(codeOutputSchema) || isLinkOutputSchema(codeOutputSchema)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return searchVariableThroughBaseOutputSchema({
|
||||
stepName,
|
||||
baseOutputSchema: codeOutputSchema,
|
||||
rawVariableName,
|
||||
});
|
||||
};
|
||||
@@ -1,127 +0,0 @@
|
||||
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
|
||||
import type { FindRecordsOutputSchema } from '@/workflow/workflow-variables/types/FindRecordsOutputSchema';
|
||||
import { searchRecordOutputSchema as searchRecordOutputSchemaUtil } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
parseVariablePath,
|
||||
} from 'twenty-shared/workflow';
|
||||
|
||||
type SearchResultKey = 'first' | 'all' | 'totalCount';
|
||||
|
||||
/**
|
||||
* Parses a variable name to extract its components for SearchRecord outputs
|
||||
* Example: "{{step1.first.user.name}}" -> { stepId: "step1", searchResultKey: "first", pathSegments: ["user"], fieldName: "name" }
|
||||
* Example: "{{step1.totalCount}}" -> { stepId: "step1", searchResultKey: "totalCount", pathSegments: [], fieldName: undefined }
|
||||
*/
|
||||
const parseVariableName = (rawVariableName: string) => {
|
||||
const variableWithoutBrackets = rawVariableName.replace(
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
(_, variableName) => variableName,
|
||||
);
|
||||
|
||||
const parts = parseVariablePath(variableWithoutBrackets);
|
||||
const stepId = parts.at(0);
|
||||
const searchResultKey = parts.at(1) as SearchResultKey;
|
||||
const remainingParts = parts.slice(2);
|
||||
|
||||
return {
|
||||
stepId,
|
||||
searchResultKey,
|
||||
fieldName: remainingParts.at(-1),
|
||||
pathSegments: remainingParts.slice(0, -1),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Searches for a variable within a search record output schema and returns its metadata
|
||||
*
|
||||
* @param stepName - Display name of the workflow step
|
||||
* @param searchRecordOutputSchema - The search record schema to search within
|
||||
* @param rawVariableName - Variable name like "{{step1.first.user.name}}" or "step1.totalCount"
|
||||
* @param isFullRecord - Whether to return info for the entire record vs specific field
|
||||
* @returns Variable metadata including labels, types, and field information
|
||||
*/
|
||||
export const searchVariableThroughFindRecordsOutputSchema = ({
|
||||
stepName,
|
||||
searchRecordOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord = false,
|
||||
stepNameLabel,
|
||||
}: {
|
||||
stepName: string;
|
||||
searchRecordOutputSchema: FindRecordsOutputSchema;
|
||||
rawVariableName: string;
|
||||
isFullRecord?: boolean;
|
||||
stepNameLabel?: string;
|
||||
}): VariableSearchResult => {
|
||||
if (!isDefined(searchRecordOutputSchema)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const { stepId, searchResultKey, fieldName, pathSegments } =
|
||||
parseVariableName(rawVariableName);
|
||||
|
||||
if (!isDefined(stepId) || !isDefined(searchResultKey)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (searchResultKey === 'first') {
|
||||
const recordSchema = searchRecordOutputSchema[searchResultKey]?.value;
|
||||
|
||||
if (!isDefined(recordSchema) || !isDefined(fieldName)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return searchRecordOutputSchemaUtil({
|
||||
stepName: `${stepName} > ${searchRecordOutputSchema[searchResultKey]?.label ?? 'First'}`,
|
||||
recordOutputSchema: recordSchema,
|
||||
selectedField: fieldName,
|
||||
path: pathSegments,
|
||||
isFullRecord,
|
||||
stepNameLabel,
|
||||
});
|
||||
}
|
||||
|
||||
if (searchResultKey === 'totalCount') {
|
||||
const label =
|
||||
searchRecordOutputSchema[searchResultKey]?.label ?? 'Total Count';
|
||||
const basePath = `${stepName} > ${label}`;
|
||||
return {
|
||||
variableLabel: label,
|
||||
variablePathLabel: stepNameLabel
|
||||
? `${basePath} (${stepNameLabel})`
|
||||
: basePath,
|
||||
variableType: FieldMetadataType.NUMBER,
|
||||
};
|
||||
}
|
||||
|
||||
if (searchResultKey === 'all') {
|
||||
const label =
|
||||
searchRecordOutputSchema[searchResultKey]?.label ?? 'All Records';
|
||||
const basePath = `${stepName} > ${label}`;
|
||||
return {
|
||||
variableLabel:
|
||||
searchRecordOutputSchema[searchResultKey]?.label ?? 'All Records',
|
||||
variablePathLabel: stepNameLabel
|
||||
? `${basePath} (${stepNameLabel})`
|
||||
: basePath,
|
||||
variableType: FieldMetadataType.ARRAY,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
};
|
||||
@@ -1,102 +0,0 @@
|
||||
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
|
||||
import type { FormOutputSchema } from '@/workflow/workflow-variables/types/FormOutputSchema';
|
||||
import { searchRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
parseVariablePath,
|
||||
} from 'twenty-shared/workflow';
|
||||
|
||||
/**
|
||||
* Parses a variable name to extract its components for Form outputs
|
||||
* Example: "{{step1.fieldName}}" -> { stepId: "step1", fieldName: "fieldName", pathSegments: [] }
|
||||
* Example: "{{step1.recordField.user.name}}" -> { stepId: "step1", fieldName: "recordField", pathSegments: ["user"], recordFieldName: "name" }
|
||||
*/
|
||||
const parseVariableName = (rawVariableName: string) => {
|
||||
const variableWithoutBrackets = rawVariableName.replace(
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
(_, variableName) => variableName,
|
||||
);
|
||||
|
||||
const parts = parseVariablePath(variableWithoutBrackets);
|
||||
const stepId = parts.at(0);
|
||||
const fieldName = parts.at(1);
|
||||
const remainingParts = parts.slice(2);
|
||||
|
||||
return {
|
||||
stepId,
|
||||
fieldName,
|
||||
pathSegments: remainingParts.slice(0, -1),
|
||||
recordFieldName: remainingParts.at(-1),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Searches for a variable within a form output schema and returns its metadata
|
||||
*
|
||||
* @param stepName - Display name of the workflow step
|
||||
* @param formOutputSchema - The form schema to search within
|
||||
* @param rawVariableName - Variable name like "{{step1.fieldName}}" or "step1.recordField.user.name"
|
||||
* @param isFullRecord - Whether to return info for the entire record vs specific field
|
||||
* @returns Variable metadata including labels, types, and field information
|
||||
*/
|
||||
export const searchVariableThroughFormOutputSchema = ({
|
||||
stepName,
|
||||
formOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord = false,
|
||||
}: {
|
||||
stepName: string;
|
||||
formOutputSchema: FormOutputSchema;
|
||||
rawVariableName: string;
|
||||
isFullRecord?: boolean;
|
||||
}): VariableSearchResult => {
|
||||
if (!isDefined(formOutputSchema)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const { stepId, fieldName, pathSegments, recordFieldName } =
|
||||
parseVariableName(rawVariableName);
|
||||
|
||||
if (!isDefined(stepId) || !isDefined(fieldName)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const formField = formOutputSchema[fieldName];
|
||||
|
||||
if (!isDefined(formField)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (formField.isLeaf) {
|
||||
return {
|
||||
variableLabel: formField.label,
|
||||
variablePathLabel: `${stepName} > ${formField.label}`,
|
||||
variableType: formField.type,
|
||||
};
|
||||
}
|
||||
|
||||
if (!formField.isLeaf && isDefined(recordFieldName)) {
|
||||
return searchRecordOutputSchema({
|
||||
stepName: `${stepName} > ${formField.label}`,
|
||||
recordOutputSchema: formField.value,
|
||||
selectedField: recordFieldName,
|
||||
path: pathSegments,
|
||||
isFullRecord,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
|
||||
import { isBaseOutputSchemaV2 } from '@/workflow/workflow-variables/types/guards/isBaseOutputSchemaV2';
|
||||
import { isRecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/guards/isRecordOutputSchemaV2';
|
||||
import { type IteratorOutputSchema } from '@/workflow/workflow-variables/types/IteratorOutputSchema';
|
||||
import { searchBaseOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema';
|
||||
import { searchRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
parseVariablePath,
|
||||
} from 'twenty-shared/workflow';
|
||||
|
||||
type IteratorResultKey =
|
||||
| 'currentItem'
|
||||
| 'currentItemIndex'
|
||||
| 'hasProcessedAllItems';
|
||||
|
||||
/**
|
||||
* Parses a variable name to extract its components
|
||||
* Example: "{{step1.currentItem.field}}" -> { stepId: "step1", iteratorResultKey: "currentItem", pathSegments: [], fieldName: "field" }
|
||||
*/
|
||||
const parseVariableName = (rawVariableName: string) => {
|
||||
const variableWithoutBrackets = rawVariableName.replace(
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
(_, variableName) => variableName,
|
||||
);
|
||||
|
||||
const parts = parseVariablePath(variableWithoutBrackets);
|
||||
const stepId = parts.at(0);
|
||||
const iteratorResultKey = parts.at(1) as IteratorResultKey;
|
||||
const remainingParts = parts.slice(2);
|
||||
|
||||
return {
|
||||
stepId,
|
||||
iteratorResultKey,
|
||||
fieldName: remainingParts.at(-1),
|
||||
pathSegments: remainingParts.slice(0, -1),
|
||||
};
|
||||
};
|
||||
|
||||
export const searchVariableThroughIteratorOutputSchema = ({
|
||||
stepName,
|
||||
iteratorOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord = false,
|
||||
}: {
|
||||
stepName: string;
|
||||
iteratorOutputSchema: IteratorOutputSchema;
|
||||
rawVariableName: string;
|
||||
isFullRecord?: boolean;
|
||||
}): VariableSearchResult => {
|
||||
if (!isDefined(iteratorOutputSchema)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const { stepId, iteratorResultKey, fieldName, pathSegments } =
|
||||
parseVariableName(rawVariableName);
|
||||
|
||||
if (!isDefined(stepId) || !isDefined(iteratorResultKey)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (iteratorResultKey === 'currentItemIndex') {
|
||||
return {
|
||||
variableLabel: 'Current Item Index',
|
||||
variablePathLabel: `${stepName} > Current Item Index`,
|
||||
variableType: FieldMetadataType.NUMBER,
|
||||
};
|
||||
}
|
||||
|
||||
if (iteratorResultKey === 'hasProcessedAllItems') {
|
||||
return {
|
||||
variableLabel: 'Has Processed All Items',
|
||||
variablePathLabel: `${stepName} > Has Processed All Items`,
|
||||
variableType: FieldMetadataType.BOOLEAN,
|
||||
};
|
||||
}
|
||||
|
||||
if (iteratorResultKey === 'currentItem') {
|
||||
const schema = iteratorOutputSchema.currentItem.value;
|
||||
|
||||
if (!isDefined(schema)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (isRecordOutputSchemaV2(schema) && isDefined(fieldName)) {
|
||||
return searchRecordOutputSchema({
|
||||
stepName: `${stepName} > Current Item`,
|
||||
recordOutputSchema: schema,
|
||||
path: pathSegments,
|
||||
selectedField: fieldName,
|
||||
isFullRecord,
|
||||
});
|
||||
}
|
||||
|
||||
if (isBaseOutputSchemaV2(schema) && isDefined(fieldName)) {
|
||||
return searchBaseOutputSchema({
|
||||
stepName,
|
||||
baseOutputSchema: schema,
|
||||
path: pathSegments,
|
||||
selectedField: fieldName,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
variableLabel: iteratorOutputSchema.currentItem.label,
|
||||
variablePathLabel: `${stepName} > ${iteratorOutputSchema.currentItem.label}`,
|
||||
variableType: iteratorOutputSchema.currentItem.isLeaf
|
||||
? iteratorOutputSchema.currentItem.type
|
||||
: 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
import { isRecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/guards/isRecordOutputSchemaV2';
|
||||
import { type ManualTriggerOutputSchema } from '@/workflow/workflow-variables/types/ManualTriggerOutputSchema';
|
||||
import { searchVariableThroughBaseOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema';
|
||||
import { searchVariableThroughRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema';
|
||||
|
||||
export const searchVariableThroughManualTriggerOutputSchema = ({
|
||||
stepName,
|
||||
manualTriggerOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
}: {
|
||||
stepName: string;
|
||||
manualTriggerOutputSchema: ManualTriggerOutputSchema;
|
||||
rawVariableName: string;
|
||||
isFullRecord: boolean;
|
||||
}) => {
|
||||
if (isRecordOutputSchemaV2(manualTriggerOutputSchema)) {
|
||||
return searchVariableThroughRecordOutputSchema({
|
||||
stepName,
|
||||
recordOutputSchema: manualTriggerOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
}
|
||||
|
||||
return searchVariableThroughBaseOutputSchema({
|
||||
stepName,
|
||||
baseOutputSchema: manualTriggerOutputSchema,
|
||||
rawVariableName,
|
||||
});
|
||||
};
|
||||
@@ -2,23 +2,11 @@ import {
|
||||
type WorkflowActionType,
|
||||
type WorkflowTriggerType,
|
||||
} from '@/workflow/types/Workflow';
|
||||
import { isCodeOutputSchema } from '@/workflow/workflow-variables/types/guards/isCodeOutputSchema';
|
||||
import { isDatabaseEventTriggerOutputSchema } from '@/workflow/workflow-variables/types/guards/isDatabaseEventTriggerOutputSchema';
|
||||
import { isFindRecordsOutputSchema } from '@/workflow/workflow-variables/types/guards/isFindRecordsOutputSchema';
|
||||
import { isFormOutputSchema } from '@/workflow/workflow-variables/types/guards/isFormOutputSchema';
|
||||
import { isIteratorOutputSchema } from '@/workflow/workflow-variables/types/guards/isIteratorOutputSchema';
|
||||
import { isManualTriggerOutputSchema } from '@/workflow/workflow-variables/types/guards/isManualTriggerOutputSchema';
|
||||
import { isRecordStepOutputSchema } from '@/workflow/workflow-variables/types/guards/isRecordStepOutputSchema';
|
||||
import { type StepOutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2';
|
||||
|
||||
import { searchVariableThroughBaseOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema';
|
||||
import { searchVariableThroughCodeOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughCodeOutputSchema';
|
||||
import { searchVariableThroughFindRecordsOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughFindRecordsOutputSchema';
|
||||
import { searchVariableThroughFormOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughFormOutputSchema';
|
||||
import { searchVariableThroughIteratorOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughIteratorOutputSchema';
|
||||
import { searchVariableThroughManualTriggerOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughManualTriggerOutputSchema';
|
||||
import { searchVariableThroughRecordEventOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordEventOutputSchema';
|
||||
import { searchVariableThroughRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema';
|
||||
import {
|
||||
searchVariableInOutputSchema,
|
||||
type VariableSearchResult,
|
||||
} from 'twenty-shared/workflow';
|
||||
|
||||
export const searchVariableThroughOutputSchemaV2 = ({
|
||||
stepOutputSchema,
|
||||
@@ -30,75 +18,13 @@ export const searchVariableThroughOutputSchemaV2 = ({
|
||||
stepType: WorkflowTriggerType | WorkflowActionType;
|
||||
rawVariableName: string;
|
||||
isFullRecord: boolean;
|
||||
}) => {
|
||||
if (isRecordStepOutputSchema(stepType, stepOutputSchema.outputSchema)) {
|
||||
return searchVariableThroughRecordOutputSchema({
|
||||
stepName: stepOutputSchema.name,
|
||||
recordOutputSchema: stepOutputSchema.outputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
}
|
||||
|
||||
if (isManualTriggerOutputSchema(stepType, stepOutputSchema.outputSchema)) {
|
||||
return searchVariableThroughManualTriggerOutputSchema({
|
||||
stepName: stepOutputSchema.name,
|
||||
manualTriggerOutputSchema: stepOutputSchema.outputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isDatabaseEventTriggerOutputSchema(stepType, stepOutputSchema.outputSchema)
|
||||
) {
|
||||
return searchVariableThroughRecordEventOutputSchema({
|
||||
stepName: stepOutputSchema.name,
|
||||
recordOutputSchema: stepOutputSchema.outputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
}
|
||||
|
||||
if (isFindRecordsOutputSchema(stepType, stepOutputSchema.outputSchema)) {
|
||||
return searchVariableThroughFindRecordsOutputSchema({
|
||||
stepName: stepOutputSchema.name,
|
||||
searchRecordOutputSchema: stepOutputSchema.outputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
stepNameLabel: stepOutputSchema.objectName,
|
||||
});
|
||||
}
|
||||
|
||||
if (isFormOutputSchema(stepType, stepOutputSchema.outputSchema)) {
|
||||
return searchVariableThroughFormOutputSchema({
|
||||
stepName: stepOutputSchema.name,
|
||||
formOutputSchema: stepOutputSchema.outputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
}
|
||||
|
||||
if (isCodeOutputSchema(stepType, stepOutputSchema.outputSchema)) {
|
||||
return searchVariableThroughCodeOutputSchema({
|
||||
stepName: stepOutputSchema.name,
|
||||
codeOutputSchema: stepOutputSchema.outputSchema,
|
||||
rawVariableName,
|
||||
});
|
||||
}
|
||||
|
||||
if (isIteratorOutputSchema(stepType, stepOutputSchema.outputSchema)) {
|
||||
return searchVariableThroughIteratorOutputSchema({
|
||||
stepName: stepOutputSchema.name,
|
||||
iteratorOutputSchema: stepOutputSchema.outputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
});
|
||||
}
|
||||
|
||||
return searchVariableThroughBaseOutputSchema({
|
||||
}): VariableSearchResult => {
|
||||
return searchVariableInOutputSchema({
|
||||
schema: stepOutputSchema.outputSchema,
|
||||
stepType,
|
||||
stepName: stepOutputSchema.name,
|
||||
baseOutputSchema: stepOutputSchema.outputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord,
|
||||
stepNameLabel: stepOutputSchema.objectName,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
|
||||
import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2';
|
||||
import { searchRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
parseVariablePath,
|
||||
} from 'twenty-shared/workflow';
|
||||
|
||||
/**
|
||||
* Parses a variable name to extract its components
|
||||
* Example: "{{step1.properties.after.user.name}}" -> { stepId: "step1", eventPrefix: "properties.after", pathSegments: ["user"], fieldName: "name" }
|
||||
*/
|
||||
const parseVariableName = (rawVariableName: string) => {
|
||||
const variableWithoutBrackets = rawVariableName.replace(
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
(_, variableName) => variableName,
|
||||
);
|
||||
|
||||
const parts = parseVariablePath(variableWithoutBrackets);
|
||||
const stepId = parts.at(0);
|
||||
// after stepId, we have a prefix (properties.after or properties.before). Path segments are the rest of the string
|
||||
// join the next 3 parts to get the event prefix (properties, after/before, objectName)
|
||||
const firstFieldWithEventPrefix = parts.slice(1, 4).join('.');
|
||||
const remainingParts = parts.slice(4);
|
||||
const partsWithoutStepId = [firstFieldWithEventPrefix, ...remainingParts];
|
||||
|
||||
return {
|
||||
stepId,
|
||||
fieldName: partsWithoutStepId.at(-1),
|
||||
pathSegments: partsWithoutStepId.slice(0, -1),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Searches for a variable within a record output schema and returns its metadata
|
||||
*
|
||||
* @param stepName - Display name of the workflow step
|
||||
* @param recordOutputSchema - The schema to search within
|
||||
* @param rawVariableName - Variable name like "{{step1.user.name}}" or "step1.user.name"
|
||||
* @param isFullRecord - Whether to return info for the entire record vs specific field
|
||||
* @returns Variable metadata including labels, types, and field information
|
||||
*/
|
||||
export const searchVariableThroughRecordEventOutputSchema = ({
|
||||
stepName,
|
||||
recordOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord = false,
|
||||
}: {
|
||||
stepName: string;
|
||||
recordOutputSchema: RecordOutputSchemaV2;
|
||||
rawVariableName: string;
|
||||
isFullRecord?: boolean;
|
||||
}): VariableSearchResult => {
|
||||
if (!isDefined(recordOutputSchema)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const { stepId, fieldName, pathSegments } =
|
||||
parseVariableName(rawVariableName);
|
||||
|
||||
if (!isDefined(stepId) || !isDefined(fieldName)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return searchRecordOutputSchema({
|
||||
stepName,
|
||||
recordOutputSchema,
|
||||
selectedField: fieldName,
|
||||
path: pathSegments,
|
||||
isFullRecord,
|
||||
});
|
||||
};
|
||||
@@ -1,219 +0,0 @@
|
||||
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
|
||||
import {
|
||||
type FieldOutputSchemaV2,
|
||||
type RecordFieldNodeValue,
|
||||
type RecordOutputSchemaV2,
|
||||
} from '@/workflow/workflow-variables/types/RecordOutputSchemaV2';
|
||||
import { isRecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/guards/isRecordOutputSchemaV2';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
parseVariablePath,
|
||||
} from 'twenty-shared/workflow';
|
||||
|
||||
const getRecordObjectLabel = (
|
||||
recordSchema: RecordOutputSchemaV2,
|
||||
): string | undefined => {
|
||||
return recordSchema.object.label;
|
||||
};
|
||||
|
||||
const getFieldFromSchema = (
|
||||
fieldKey: string,
|
||||
recordSchema: RecordFieldNodeValue,
|
||||
): FieldOutputSchemaV2 | undefined => {
|
||||
return isRecordOutputSchemaV2(recordSchema)
|
||||
? recordSchema.fields[fieldKey]
|
||||
: recordSchema[fieldKey];
|
||||
};
|
||||
|
||||
const getCompositeSubFieldName = (
|
||||
recordSchema: RecordFieldNodeValue,
|
||||
fieldKey: string,
|
||||
): string | undefined => {
|
||||
return isRecordOutputSchemaV2(recordSchema)
|
||||
? undefined
|
||||
: recordSchema[fieldKey]?.isCompositeSubField
|
||||
? fieldKey
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const isIdFieldName = (fieldName: string) => {
|
||||
return (
|
||||
fieldName === 'id' ||
|
||||
// For database events, id field will have a prefix such as properties.after.id
|
||||
fieldName.endsWith('.id')
|
||||
);
|
||||
};
|
||||
|
||||
const navigateToTargetField = (
|
||||
startingSchema: RecordOutputSchemaV2,
|
||||
pathSegments: string[],
|
||||
): { schema: RecordFieldNodeValue; pathLabels: string[] } | null => {
|
||||
let currentSchema: RecordFieldNodeValue = startingSchema;
|
||||
const pathLabels: string[] = [];
|
||||
|
||||
for (const pathSegment of pathSegments) {
|
||||
const field = getFieldFromSchema(pathSegment, currentSchema);
|
||||
|
||||
if (!isDefined(field)) {
|
||||
return null; // Path not found
|
||||
}
|
||||
|
||||
if (isDefined(field.label)) {
|
||||
pathLabels.push(field.label);
|
||||
}
|
||||
|
||||
const nextSchema = field.value;
|
||||
if (!isDefined(nextSchema)) {
|
||||
return null; // Dead end in path
|
||||
}
|
||||
|
||||
currentSchema = nextSchema;
|
||||
}
|
||||
|
||||
return { schema: currentSchema, pathLabels };
|
||||
};
|
||||
|
||||
const buildVariableResult = (
|
||||
stepName: string,
|
||||
pathLabels: string[],
|
||||
targetSchema: RecordFieldNodeValue,
|
||||
targetFieldName: string,
|
||||
isFullRecord: boolean,
|
||||
stepNameLabel?: string,
|
||||
): VariableSearchResult => {
|
||||
const targetField = getFieldFromSchema(targetFieldName, targetSchema);
|
||||
// Determine the variable label based on whether we want the full record or a specific field
|
||||
const variableLabel =
|
||||
isFullRecord &&
|
||||
isRecordOutputSchemaV2(targetSchema) &&
|
||||
isIdFieldName(targetFieldName)
|
||||
? getRecordObjectLabel(targetSchema)
|
||||
: targetField?.label;
|
||||
|
||||
if (!variableLabel) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
variableType: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Build the full path: stepName > field1 > field2 > targetField
|
||||
const fullPathSegments = [stepName, ...pathLabels, variableLabel];
|
||||
const basePath = fullPathSegments.join(' > ');
|
||||
const variablePathLabel = stepNameLabel
|
||||
? `${basePath} (${stepNameLabel})`
|
||||
: basePath;
|
||||
|
||||
return {
|
||||
variableLabel,
|
||||
variablePathLabel,
|
||||
variableType: targetField?.type,
|
||||
fieldMetadataId: targetField?.fieldMetadataId,
|
||||
compositeFieldSubFieldName: getCompositeSubFieldName(
|
||||
targetSchema,
|
||||
targetFieldName,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const searchRecordOutputSchema = ({
|
||||
stepName,
|
||||
recordOutputSchema,
|
||||
path,
|
||||
selectedField,
|
||||
isFullRecord,
|
||||
stepNameLabel,
|
||||
}: {
|
||||
stepName: string;
|
||||
recordOutputSchema: RecordOutputSchemaV2;
|
||||
path: string[];
|
||||
selectedField: string;
|
||||
isFullRecord: boolean;
|
||||
stepNameLabel?: string;
|
||||
}): VariableSearchResult => {
|
||||
const navigationResult = navigateToTargetField(recordOutputSchema, path);
|
||||
|
||||
if (!navigationResult) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
variableType: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return buildVariableResult(
|
||||
stepName,
|
||||
navigationResult.pathLabels,
|
||||
navigationResult.schema,
|
||||
selectedField,
|
||||
isFullRecord,
|
||||
stepNameLabel,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a variable name to extract its components
|
||||
* Example: "{{step1.user.name}}" -> { stepId: "step1", pathSegments: ["user"], fieldName: "name" }
|
||||
*/
|
||||
const parseVariableName = (rawVariableName: string) => {
|
||||
const variableWithoutBrackets = rawVariableName.replace(
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
(_, variableName) => variableName,
|
||||
);
|
||||
|
||||
const parts = parseVariablePath(variableWithoutBrackets);
|
||||
|
||||
return {
|
||||
stepId: parts.at(0),
|
||||
fieldName: parts.at(-1),
|
||||
pathSegments: parts.slice(1, -1), // Everything between stepId and fieldName
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Searches for a variable within a record output schema and returns its metadata
|
||||
*
|
||||
* @param stepName - Display name of the workflow step
|
||||
* @param recordOutputSchema - The schema to search within
|
||||
* @param rawVariableName - Variable name like "{{step1.user.name}}" or "step1.user.name"
|
||||
* @param isFullRecord - Whether to return info for the entire record vs specific field
|
||||
* @returns Variable metadata including labels, types, and field information
|
||||
*/
|
||||
export const searchVariableThroughRecordOutputSchema = ({
|
||||
stepName,
|
||||
recordOutputSchema,
|
||||
rawVariableName,
|
||||
isFullRecord = false,
|
||||
}: {
|
||||
stepName: string;
|
||||
recordOutputSchema: RecordOutputSchemaV2;
|
||||
rawVariableName: string;
|
||||
isFullRecord?: boolean;
|
||||
}): VariableSearchResult => {
|
||||
if (!isDefined(recordOutputSchema)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const { stepId, fieldName, pathSegments } =
|
||||
parseVariableName(rawVariableName);
|
||||
|
||||
if (!isDefined(stepId) || !isDefined(fieldName)) {
|
||||
return {
|
||||
variableLabel: undefined,
|
||||
variablePathLabel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return searchRecordOutputSchema({
|
||||
stepName,
|
||||
recordOutputSchema,
|
||||
selectedField: fieldName,
|
||||
path: pathSegments,
|
||||
isFullRecord,
|
||||
});
|
||||
};
|
||||
@@ -1,28 +1,30 @@
|
||||
import { checkUrlType } from '~/utils/checkUrlType';
|
||||
import { LinkType } from 'twenty-ui-deprecated/navigation';
|
||||
|
||||
describe('checkUrlType', () => {
|
||||
it('should return "linkedin", if linkedin url', () => {
|
||||
expect(checkUrlType('https://www.linkedin.com/in/håkan-fisk')).toBe(
|
||||
'linkedin',
|
||||
it('should detect LinkedIn urls', () => {
|
||||
expect(checkUrlType('https://www.linkedin.com/in/john')).toBe(
|
||||
LinkType.LinkedIn,
|
||||
);
|
||||
expect(checkUrlType('http://www.linkedin.com/in/håkan-fisk')).toBe(
|
||||
'linkedin',
|
||||
expect(checkUrlType('linkedin.com/company/twenty')).toBe(LinkType.LinkedIn);
|
||||
});
|
||||
|
||||
it('should detect Twitter urls', () => {
|
||||
expect(checkUrlType('https://twitter.com/twenty')).toBe(LinkType.Twitter);
|
||||
});
|
||||
|
||||
it('should detect X urls as Twitter', () => {
|
||||
expect(checkUrlType('https://x.com/twenty')).toBe(LinkType.Twitter);
|
||||
});
|
||||
|
||||
it('should detect Facebook urls', () => {
|
||||
expect(checkUrlType('https://www.facebook.com/twenty')).toBe(
|
||||
LinkType.Facebook,
|
||||
);
|
||||
expect(checkUrlType('https://linkedin.com/in/håkan-fisk')).toBe('linkedin');
|
||||
expect(checkUrlType('http://linkedin.com/in/håkan-fisk')).toBe('linkedin');
|
||||
expect(checkUrlType('linkedin.com/in/håkan-fisk')).toBe('linkedin');
|
||||
});
|
||||
|
||||
it('should return "twitter", if twitter url', () => {
|
||||
expect(checkUrlType('https://www.twitter.com/john-doe')).toBe('twitter');
|
||||
expect(checkUrlType('https://www.x.com/john-doe')).toBe('twitter');
|
||||
});
|
||||
|
||||
it('should return "url", if neither linkedin nor twitter url', () => {
|
||||
expect(checkUrlType('https://www.example.com')).toBe('url');
|
||||
});
|
||||
|
||||
it('should return "facebook", if facebook url', () => {
|
||||
expect(checkUrlType('https://www.facebook.com/john-doe')).toBe('facebook');
|
||||
it('should fall back to a generic url type', () => {
|
||||
expect(checkUrlType('https://example.com')).toBe(LinkType.Url);
|
||||
expect(checkUrlType('not-a-url')).toBe(LinkType.Url);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { compareNonEmptyStrings } from '~/utils/compareNonEmptyStrings';
|
||||
|
||||
describe('compareNonEmptyStrings', () => {
|
||||
it('should return true when both values are empty or nullish', () => {
|
||||
expect(compareNonEmptyStrings(null, undefined)).toBe(true);
|
||||
expect(compareNonEmptyStrings('', null)).toBe(true);
|
||||
expect(compareNonEmptyStrings('', '')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when both values are equal non-empty strings', () => {
|
||||
expect(compareNonEmptyStrings('foo', 'foo')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when values differ', () => {
|
||||
expect(compareNonEmptyStrings('foo', 'bar')).toBe(false);
|
||||
expect(compareNonEmptyStrings('foo', '')).toBe(false);
|
||||
expect(compareNonEmptyStrings('foo', null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,49 +1,24 @@
|
||||
import { compareStrictlyExceptForNullAndUndefined } from '~/utils/compareStrictlyExceptForNullAndUndefined';
|
||||
|
||||
describe('compareStrictlyExceptForNullAndUndefined', () => {
|
||||
it('should return true for undefined === null', () => {
|
||||
it('should return true when both values are nullish', () => {
|
||||
expect(compareStrictlyExceptForNullAndUndefined(null, undefined)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(compareStrictlyExceptForNullAndUndefined(undefined, null)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return true for null === undefined', () => {
|
||||
expect(compareStrictlyExceptForNullAndUndefined(null, undefined)).toBe(
|
||||
true,
|
||||
);
|
||||
it('should compare strictly when at least one value is defined', () => {
|
||||
expect(compareStrictlyExceptForNullAndUndefined(1, 1)).toBe(true);
|
||||
expect(compareStrictlyExceptForNullAndUndefined('a', 'a')).toBe(true);
|
||||
expect(compareStrictlyExceptForNullAndUndefined(1, 2)).toBe(false);
|
||||
expect(compareStrictlyExceptForNullAndUndefined(1, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for undefined === undefined', () => {
|
||||
expect(compareStrictlyExceptForNullAndUndefined(undefined, undefined)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return true for null === null', () => {
|
||||
expect(compareStrictlyExceptForNullAndUndefined(null, null)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for 2 === 2', () => {
|
||||
expect(compareStrictlyExceptForNullAndUndefined(2, 2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for 2 === 3', () => {
|
||||
expect(compareStrictlyExceptForNullAndUndefined(2, 3)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for undefined === 2', () => {
|
||||
expect(compareStrictlyExceptForNullAndUndefined(undefined, 2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null === 2', () => {
|
||||
expect(compareStrictlyExceptForNullAndUndefined(null, 2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for 2 === "2"', () => {
|
||||
expect(compareStrictlyExceptForNullAndUndefined(2, '2')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for "2" === "2"', () => {
|
||||
expect(compareStrictlyExceptForNullAndUndefined('2', '2')).toBe(true);
|
||||
it('should not treat zero or empty string as nullish', () => {
|
||||
expect(compareStrictlyExceptForNullAndUndefined(0, null)).toBe(false);
|
||||
expect(compareStrictlyExceptForNullAndUndefined('', undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/
|
||||
import { WorkflowStepPositionInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-step-position.input';
|
||||
import { WorkflowStepConnectionOptions } from 'src/modules/workflow/workflow-builder/workflow-version-step/types/WorkflowStepCreationOptions';
|
||||
import { WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
|
||||
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
|
||||
@InputType()
|
||||
export class CreateWorkflowVersionStepInput {
|
||||
|
||||
@@ -4,7 +4,7 @@ import graphqlTypeJson from 'graphql-type-json';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { WorkflowStepPosition } from 'src/engine/core-modules/workflow/dtos/workflow-step-position.dto';
|
||||
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
|
||||
registerEnumType(WorkflowActionType, {
|
||||
name: 'WorkflowActionType',
|
||||
|
||||
@@ -74,6 +74,11 @@ Always rely on tool schema definitions:
|
||||
- Follow schema definitions exactly for field names, types, and structures
|
||||
- Schema includes validation rules and common patterns
|
||||
|
||||
## Validation
|
||||
|
||||
The \`create_complete_workflow\` and \`update_workflow_version_step\` tools automatically run validation after their operation and include the results in the response. Review any reported errors and fix them before activating the workflow.
|
||||
|
||||
|
||||
## Approach
|
||||
|
||||
- Ask clarifying questions to understand user needs
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
WorkflowStatus,
|
||||
type WorkflowWorkspaceEntity,
|
||||
} from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
|
||||
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import {
|
||||
WorkflowTriggerException,
|
||||
WorkflowTriggerExceptionCode,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import { computeWorkflowVersionStepChanges } from 'src/modules/workflow/workflow-builder/utils/compute-workflow-version-step-updates.util';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import {
|
||||
type WorkflowTrigger,
|
||||
WorkflowTriggerType,
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
|
||||
import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service';
|
||||
|
||||
@Module({
|
||||
imports: [WorkflowCommonModule, FeatureFlagModule],
|
||||
imports: [
|
||||
WorkflowCommonModule,
|
||||
FeatureFlagModule,
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
],
|
||||
providers: [WorkflowSchemaWorkspaceService],
|
||||
exports: [WorkflowSchemaWorkspaceService],
|
||||
})
|
||||
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
navigateOutputSchemaProperty,
|
||||
SingleRecordAvailability,
|
||||
TRIGGER_STEP_ID,
|
||||
WorkflowActionType,
|
||||
} from 'twenty-shared/workflow';
|
||||
|
||||
import { type DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
|
||||
import { checkStringIsDatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/utils/check-string-is-database-event-action';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { generateFakeValue } from 'src/engine/utils/generate-fake-value';
|
||||
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
|
||||
import { DEFAULT_ITERATOR_CURRENT_ITEM } from 'src/modules/workflow/workflow-builder/workflow-schema/constants/default-iterator-current-item.const';
|
||||
@@ -29,10 +31,7 @@ import { generateFakeObjectRecord } from 'src/modules/workflow/workflow-builder/
|
||||
import { generateFakeObjectRecordEvent } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-object-record-event';
|
||||
import { inferArrayItemSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/infer-array-item-schema';
|
||||
import { type FormFieldMetadata } from 'src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import {
|
||||
WorkflowTrigger,
|
||||
WorkflowTriggerType,
|
||||
@@ -42,6 +41,7 @@ import {
|
||||
export class WorkflowSchemaWorkspaceService {
|
||||
constructor(
|
||||
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
|
||||
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
) {}
|
||||
|
||||
async computeStepOutputSchema({
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import { type WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
|
||||
import { type WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service';
|
||||
import {
|
||||
type WorkflowAiAgentAction,
|
||||
type WorkflowFindRecordsAction,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { WorkflowValidationWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.workspace-service';
|
||||
|
||||
const WORKSPACE_ID = 'workspace-id';
|
||||
|
||||
const ERROR_HANDLING_OPTIONS = {
|
||||
retryOnFailure: { value: false },
|
||||
continueOnFailure: { value: false },
|
||||
};
|
||||
|
||||
const buildService = (objectIdByNameSingular: Record<string, string> = {}) => {
|
||||
const workflowCommonWorkspaceService = {
|
||||
getWorkflowVersionOrFail: jest.fn(),
|
||||
getFlatEntityMaps: jest.fn().mockResolvedValue({ objectIdByNameSingular }),
|
||||
} as unknown as jest.Mocked<WorkflowCommonWorkspaceService>;
|
||||
|
||||
const workflowSchemaWorkspaceService = {
|
||||
computeStepOutputSchema: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as jest.Mocked<WorkflowSchemaWorkspaceService>;
|
||||
|
||||
return new WorkflowValidationWorkspaceService(
|
||||
workflowCommonWorkspaceService,
|
||||
workflowSchemaWorkspaceService,
|
||||
);
|
||||
};
|
||||
|
||||
const buildAiAgentStep = (input: {
|
||||
agentId?: string;
|
||||
prompt?: string;
|
||||
}): WorkflowAiAgentAction => ({
|
||||
id: 'ai-agent-step',
|
||||
name: 'AI Agent',
|
||||
type: WorkflowActionType.AI_AGENT,
|
||||
valid: true,
|
||||
settings: {
|
||||
input,
|
||||
outputSchema: {
|
||||
result: { isLeaf: true, type: 'string', label: 'result', value: '' },
|
||||
},
|
||||
errorHandlingOptions: ERROR_HANDLING_OPTIONS,
|
||||
},
|
||||
});
|
||||
|
||||
const buildFindRecordsStep = (
|
||||
objectName: string,
|
||||
): WorkflowFindRecordsAction => ({
|
||||
id: 'find-records-step',
|
||||
name: 'Find Records',
|
||||
type: WorkflowActionType.FIND_RECORDS,
|
||||
valid: true,
|
||||
settings: {
|
||||
input: { objectName },
|
||||
outputSchema: {},
|
||||
errorHandlingOptions: ERROR_HANDLING_OPTIONS,
|
||||
},
|
||||
});
|
||||
|
||||
describe('WorkflowValidationWorkspaceService', () => {
|
||||
it('should flag an AI Agent step that has no agent selected', async () => {
|
||||
const service = buildService();
|
||||
|
||||
const result = await service.validateWorkflowDefinition({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
trigger: null,
|
||||
steps: [buildAiAgentStep({})],
|
||||
});
|
||||
|
||||
expect(result.errors.map((issue) => issue.code)).toContain(
|
||||
'AI_AGENT_MISSING_AGENT',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not flag an AI Agent step that has an agent selected', async () => {
|
||||
const service = buildService();
|
||||
|
||||
const result = await service.validateWorkflowDefinition({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
trigger: null,
|
||||
steps: [buildAiAgentStep({ agentId: 'agent-1' })],
|
||||
});
|
||||
|
||||
expect(result.errors.map((issue) => issue.code)).not.toContain(
|
||||
'AI_AGENT_MISSING_AGENT',
|
||||
);
|
||||
});
|
||||
|
||||
it('should flag a record step targeting an object that does not exist in the workspace', async () => {
|
||||
const service = buildService({});
|
||||
|
||||
const result = await service.validateWorkflowDefinition({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
trigger: null,
|
||||
steps: [buildFindRecordsStep('ghost')],
|
||||
});
|
||||
|
||||
expect(
|
||||
result.errors.some((issue) =>
|
||||
issue.message.includes('does not exist in this workspace'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should not flag a record step targeting an existing object', async () => {
|
||||
const service = buildService({ person: 'object-id-1' });
|
||||
|
||||
const result = await service.validateWorkflowDefinition({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
trigger: null,
|
||||
steps: [buildFindRecordsStep('person')],
|
||||
});
|
||||
|
||||
expect(
|
||||
result.errors.some((issue) =>
|
||||
issue.message.includes('does not exist in this workspace'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
|
||||
import { WorkflowSchemaModule } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.module';
|
||||
|
||||
import { WorkflowValidationWorkspaceService } from './workflow-validation.workspace-service';
|
||||
|
||||
@Module({
|
||||
imports: [WorkflowCommonModule, WorkflowSchemaModule],
|
||||
providers: [WorkflowValidationWorkspaceService],
|
||||
exports: [WorkflowValidationWorkspaceService],
|
||||
})
|
||||
export class WorkflowValidationModule {}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isNonEmptyString, isObject, isString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
validateWorkflowStructure,
|
||||
type WorkflowValidationIssue,
|
||||
type WorkflowValidationResult,
|
||||
WorkflowActionType,
|
||||
} from 'twenty-shared/workflow';
|
||||
|
||||
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
|
||||
import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
type WorkflowAiAgentAction,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { type WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
|
||||
|
||||
const RECORD_CRUD_ACTION_TYPES = new Set<WorkflowActionType>([
|
||||
WorkflowActionType.CREATE_RECORD,
|
||||
WorkflowActionType.UPDATE_RECORD,
|
||||
WorkflowActionType.DELETE_RECORD,
|
||||
WorkflowActionType.UPSERT_RECORD,
|
||||
WorkflowActionType.FIND_RECORDS,
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class WorkflowValidationWorkspaceService {
|
||||
constructor(
|
||||
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
|
||||
private readonly workflowSchemaWorkspaceService: WorkflowSchemaWorkspaceService,
|
||||
) {}
|
||||
|
||||
async validateWorkflowVersion({
|
||||
workspaceId,
|
||||
workflowVersionId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
workflowVersionId: string;
|
||||
}): Promise<WorkflowValidationResult> {
|
||||
const workflowVersion =
|
||||
await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({
|
||||
workspaceId,
|
||||
workflowVersionId,
|
||||
});
|
||||
|
||||
return this.validateWorkflowDefinition({
|
||||
workspaceId,
|
||||
workflowVersionId,
|
||||
trigger: workflowVersion.trigger,
|
||||
steps: workflowVersion.steps,
|
||||
});
|
||||
}
|
||||
|
||||
async validateWorkflowDefinition({
|
||||
workspaceId,
|
||||
workflowVersionId,
|
||||
trigger,
|
||||
steps,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
workflowVersionId?: string;
|
||||
trigger: WorkflowTrigger | null;
|
||||
steps: WorkflowAction[] | null;
|
||||
}): Promise<WorkflowValidationResult> {
|
||||
const { trigger: enrichedTrigger, steps: enrichedSteps } =
|
||||
await this.enrichOutputSchemas({
|
||||
workspaceId,
|
||||
workflowVersionId,
|
||||
trigger,
|
||||
steps,
|
||||
});
|
||||
|
||||
const staticResult = validateWorkflowStructure({
|
||||
trigger: enrichedTrigger,
|
||||
steps: enrichedSteps,
|
||||
});
|
||||
|
||||
const semanticIssues = this.validateStepTypeRequirements({
|
||||
steps: enrichedSteps ?? [],
|
||||
});
|
||||
|
||||
const metadataIssues = await this.validateWorkspaceMetadata({
|
||||
workspaceId,
|
||||
steps: enrichedSteps ?? [],
|
||||
});
|
||||
|
||||
return mergeValidationResults(staticResult, [
|
||||
...semanticIssues,
|
||||
...metadataIssues,
|
||||
]);
|
||||
}
|
||||
|
||||
private async enrichOutputSchemas({
|
||||
workspaceId,
|
||||
workflowVersionId,
|
||||
trigger,
|
||||
steps,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
workflowVersionId?: string;
|
||||
trigger: WorkflowTrigger | null;
|
||||
steps: WorkflowAction[] | null;
|
||||
}): Promise<{
|
||||
trigger: WorkflowTrigger | null;
|
||||
steps: WorkflowAction[] | null;
|
||||
}> {
|
||||
const enrichedTrigger = isDefined(trigger)
|
||||
? await this.withComputedOutputSchema({
|
||||
step: trigger,
|
||||
workspaceId,
|
||||
workflowVersionId,
|
||||
})
|
||||
: trigger;
|
||||
|
||||
const enrichedSteps = isDefined(steps)
|
||||
? await Promise.all(
|
||||
steps.map((step) =>
|
||||
this.withComputedOutputSchema({
|
||||
step,
|
||||
workspaceId,
|
||||
workflowVersionId,
|
||||
}),
|
||||
),
|
||||
)
|
||||
: steps;
|
||||
|
||||
return { trigger: enrichedTrigger, steps: enrichedSteps };
|
||||
}
|
||||
|
||||
private async withComputedOutputSchema<
|
||||
TStep extends WorkflowTrigger | WorkflowAction,
|
||||
>({
|
||||
step,
|
||||
workspaceId,
|
||||
workflowVersionId,
|
||||
}: {
|
||||
step: TStep;
|
||||
workspaceId: string;
|
||||
workflowVersionId?: string;
|
||||
}): Promise<TStep> {
|
||||
try {
|
||||
const computedSchema =
|
||||
await this.workflowSchemaWorkspaceService.computeStepOutputSchema({
|
||||
step,
|
||||
workspaceId,
|
||||
workflowVersionId,
|
||||
});
|
||||
|
||||
if (
|
||||
!isDefined(computedSchema) ||
|
||||
Object.keys(computedSchema).length === 0
|
||||
) {
|
||||
return step;
|
||||
}
|
||||
|
||||
return {
|
||||
...step,
|
||||
settings: { ...step.settings, outputSchema: computedSchema },
|
||||
};
|
||||
} catch {
|
||||
// Output schema enrichment is best-effort: if it cannot be computed,
|
||||
// validation still runs against the step's existing settings rather
|
||||
// than failing the whole validation.
|
||||
return step;
|
||||
}
|
||||
}
|
||||
|
||||
private validateStepTypeRequirements({
|
||||
steps,
|
||||
}: {
|
||||
steps: WorkflowAction[];
|
||||
}): WorkflowValidationIssue[] {
|
||||
const issues: WorkflowValidationIssue[] = [];
|
||||
|
||||
for (const step of steps) {
|
||||
switch (step.type) {
|
||||
case WorkflowActionType.AI_AGENT:
|
||||
issues.push(...this.validateAiAgentStep(step));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private validateAiAgentStep(
|
||||
step: WorkflowAiAgentAction,
|
||||
): WorkflowValidationIssue[] {
|
||||
const issues: WorkflowValidationIssue[] = [];
|
||||
|
||||
if (!isNonEmptyString(step.settings?.input?.agentId)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'AI_AGENT_MISSING_AGENT',
|
||||
message: `AI Agent step "${step.name ?? step.id}" has no agent selected.`,
|
||||
stepId: step.id,
|
||||
});
|
||||
}
|
||||
|
||||
const outputSchema = step.settings?.outputSchema;
|
||||
const hasOutputSchema =
|
||||
isDefined(outputSchema) && Object.keys(outputSchema).length > 0;
|
||||
|
||||
if (!hasOutputSchema) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
code: 'AI_AGENT_MISSING_OUTPUT_VARIABLE',
|
||||
message: `AI Agent step "${step.name ?? step.id}" has no output variable defined. Downstream steps won't be able to reference its result.`,
|
||||
stepId: step.id,
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private async validateWorkspaceMetadata({
|
||||
workspaceId,
|
||||
steps,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
steps: WorkflowAction[];
|
||||
}): Promise<WorkflowValidationIssue[]> {
|
||||
const recordSteps = steps.filter((step) =>
|
||||
RECORD_CRUD_ACTION_TYPES.has(step.type),
|
||||
);
|
||||
|
||||
if (recordSteps.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { objectIdByNameSingular } =
|
||||
await this.workflowCommonWorkspaceService.getFlatEntityMaps(workspaceId);
|
||||
|
||||
const issues: WorkflowValidationIssue[] = [];
|
||||
|
||||
for (const step of recordSteps) {
|
||||
const input = step.settings.input;
|
||||
const objectName =
|
||||
isObject(input) && 'objectName' in input ? input.objectName : undefined;
|
||||
|
||||
if (!isString(objectName)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'INVALID_STEP_PARAMS',
|
||||
message: `Step "${step.name ?? step.id}" has an invalid object name.`,
|
||||
stepId: step.id,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isDefined(objectIdByNameSingular[objectName])) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'INVALID_STEP_PARAMS',
|
||||
message: `Step "${step.name ?? step.id}" targets object "${objectName}" which does not exist in this workspace.`,
|
||||
stepId: step.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
}
|
||||
|
||||
const mergeValidationResults = (
|
||||
baseResult: WorkflowValidationResult,
|
||||
additionalIssues: WorkflowValidationIssue[],
|
||||
): WorkflowValidationResult => {
|
||||
const errors = [
|
||||
...baseResult.errors,
|
||||
...additionalIssues.filter((issue) => issue.severity === 'error'),
|
||||
];
|
||||
const warnings = [
|
||||
...baseResult.warnings,
|
||||
...additionalIssues.filter((issue) => issue.severity === 'warning'),
|
||||
];
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
};
|
||||
@@ -1,16 +1,13 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
|
||||
import { TRIGGER_STEP_ID, WorkflowActionType } from 'twenty-shared/workflow';
|
||||
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
|
||||
import { type WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
|
||||
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
|
||||
import { WorkflowVersionEdgeWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-edge/workflow-version-edge.workspace-service';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { WorkflowTriggerType } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
|
||||
|
||||
type MockWorkspaceRepository = Partial<
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
|
||||
import { TRIGGER_STEP_ID, WorkflowActionType } from 'twenty-shared/workflow';
|
||||
|
||||
import { type WorkflowVersionStepChangesDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
@@ -16,10 +16,7 @@ import { assertWorkflowVersionIsDraft } from 'src/modules/workflow/common/utils/
|
||||
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
|
||||
import { computeWorkflowVersionStepChanges } from 'src/modules/workflow/workflow-builder/utils/compute-workflow-version-step-updates.util';
|
||||
import { WorkflowStepConnectionOptions } from 'src/modules/workflow/workflow-builder/workflow-version-step/types/WorkflowStepCreationOptions';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { type WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import { SEED_WORKFLOW_ACTION_TRIGGER_SETTINGS } from 'twenty-shared/logic-function';
|
||||
|
||||
import { AiAgentRoleService } from 'src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service';
|
||||
@@ -17,10 +18,7 @@ import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/works
|
||||
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
|
||||
import { CodeStepBuildService } from 'src/modules/workflow/workflow-builder/workflow-version-step/code-step/services/code-step-build.service';
|
||||
import { WorkflowVersionStepOperationsWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
const mockWorkspaceId = 'workspace-id';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
|
||||
import { TRIGGER_STEP_ID, WorkflowActionType } from 'twenty-shared/workflow';
|
||||
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
|
||||
@@ -13,10 +13,7 @@ import { WorkflowVersionStepHelpersWorkspaceService } from 'src/modules/workflow
|
||||
import { WorkflowVersionStepOperationsWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service';
|
||||
import { WorkflowVersionStepUpdateWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-update.workspace-service';
|
||||
import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { WorkflowTriggerType } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
|
||||
|
||||
jest.mock(
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
extractCodeStepLogicFunctionIdsFromWorkflowSteps,
|
||||
type NonCodeStepForLogicFunctionIdExtraction,
|
||||
} from 'src/modules/workflow/workflow-builder/workflow-version-step/code-step/utils/extract-code-step-logic-function-ids-from-workflow-steps.util';
|
||||
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
|
||||
const buildCodeStep = (
|
||||
logicFunctionId: string,
|
||||
|
||||
@@ -4,11 +4,11 @@ import {
|
||||
WorkflowVersionStepException,
|
||||
WorkflowVersionStepExceptionCode,
|
||||
} from 'src/modules/workflow/common/exceptions/workflow-version-step.exception';
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import { type WorkflowCodeActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-input.type';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
type WorkflowCodeAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
export type CodeStepForLogicFunctionIdExtraction = Pick<
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { type WorkflowActionType } from 'twenty-shared/workflow';
|
||||
|
||||
type WorkflowIteratorStepConnectionOptions = {
|
||||
connectedStepType: WorkflowActionType.ITERATOR;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
|
||||
import { TRIGGER_STEP_ID, WorkflowActionType } from 'twenty-shared/workflow';
|
||||
|
||||
import { insertStep } from 'src/modules/workflow/workflow-builder/workflow-version-step/utils/insert-step';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
type WorkflowIteratorAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import {
|
||||
type WorkflowTrigger,
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
// @ts-nocheck
|
||||
// Disabled type checking due to tsgo performance issue with deep spread operations
|
||||
// See: https://github.com/microsoft/typescript-go/issues/2551
|
||||
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
|
||||
import { TRIGGER_STEP_ID, WorkflowActionType } from 'twenty-shared/workflow';
|
||||
|
||||
import { removeStep } from 'src/modules/workflow/workflow-builder/workflow-version-step/utils/remove-step';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import {
|
||||
type WorkflowTrigger,
|
||||
WorkflowTriggerType,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
|
||||
import { TRIGGER_STEP_ID, WorkflowActionType } from 'twenty-shared/workflow';
|
||||
|
||||
import {
|
||||
WorkflowVersionStepException,
|
||||
@@ -7,10 +7,7 @@ import {
|
||||
} from 'src/modules/workflow/common/exceptions/workflow-version-step.exception';
|
||||
import { type WorkflowStepConnectionOptions } from 'src/modules/workflow/workflow-builder/workflow-version-step/types/WorkflowStepCreationOptions';
|
||||
import { type WorkflowIteratorActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/types/workflow-iterator-action-settings.type';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { type WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
|
||||
|
||||
export const insertStep = ({
|
||||
|
||||
@@ -3,13 +3,13 @@ import {
|
||||
IF_ELSE_BRANCH_POSITION_OFFSETS,
|
||||
TRIGGER_STEP_ID,
|
||||
type StepIfElseBranch,
|
||||
WorkflowActionType,
|
||||
} from 'twenty-shared/workflow';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { isWorkflowEmptyAction } from 'src/modules/workflow/workflow-executor/workflow-actions/empty/guards/is-workflow-empty-action.guard';
|
||||
import { isWorkflowIfElseAction } from 'src/modules/workflow/workflow-executor/workflow-actions/if-else/guards/is-workflow-if-else-action.guard';
|
||||
import {
|
||||
WorkflowActionType,
|
||||
type WorkflowAction,
|
||||
type WorkflowIteratorAction,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
IF_ELSE_BRANCH_POSITION_OFFSETS,
|
||||
getFunctionInputFromInputSchema,
|
||||
type StepIfElseBranch,
|
||||
WorkflowActionType,
|
||||
} from 'twenty-shared/workflow';
|
||||
import { Repository } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
@@ -44,7 +45,6 @@ import { type OutputSchema } from 'src/modules/workflow/workflow-builder/workflo
|
||||
import { CodeStepBuildService } from 'src/modules/workflow/workflow-builder/workflow-version-step/code-step/services/code-step-build.service';
|
||||
import { type BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
|
||||
import {
|
||||
WorkflowActionType,
|
||||
type WorkflowAction,
|
||||
type WorkflowEmptyAction,
|
||||
type WorkflowFormAction,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
|
||||
import { TRIGGER_STEP_ID, WorkflowActionType } from 'twenty-shared/workflow';
|
||||
|
||||
import { WithLock } from 'src/engine/core-modules/cache-lock/with-lock.decorator';
|
||||
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
|
||||
@@ -25,10 +25,7 @@ import { assertWorkflowVersionIsDraft } from 'src/modules/workflow/common/utils/
|
||||
import { assertWorkflowVersionTriggerIsDefined } from 'src/modules/workflow/common/utils/assert-workflow-version-trigger-is-defined.util';
|
||||
import { WorkflowVersionStepOperationsWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service';
|
||||
import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service';
|
||||
import {
|
||||
WorkflowActionType,
|
||||
type WorkflowAction,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
@Injectable()
|
||||
export class WorkflowVersionWorkspaceService {
|
||||
|
||||
@@ -23,7 +23,7 @@ import { DeleteRecordWorkflowAction } from 'src/modules/workflow/workflow-execut
|
||||
import { FindRecordsWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action';
|
||||
import { UpdateRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action';
|
||||
import { UpsertRecordWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/upsert-record.workflow-action';
|
||||
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
|
||||
@Injectable()
|
||||
export class WorkflowActionFactory {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { StepStatus } from 'twenty-shared/workflow';
|
||||
import { StepStatus, WorkflowActionType } from 'twenty-shared/workflow';
|
||||
|
||||
import { shouldExecuteChildStep } from 'src/modules/workflow/workflow-executor/utils/should-execute-child-step.util';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
describe('shouldExecuteChildStep', () => {
|
||||
const parentSteps = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { StepStatus } from 'twenty-shared/workflow';
|
||||
import { StepStatus, WorkflowActionType } from 'twenty-shared/workflow';
|
||||
|
||||
import { WorkflowRunStatus } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity';
|
||||
import {
|
||||
@@ -6,10 +6,7 @@ import {
|
||||
createMockIfElseStep,
|
||||
} from 'src/modules/workflow/workflow-executor/utils/create-mock-workflow-steps.util';
|
||||
import { shouldExecuteStep } from 'src/modules/workflow/workflow-executor/utils/should-execute-step.util';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
describe('shouldExecuteStep', () => {
|
||||
const steps = [
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { type StepIfElseBranch } from 'twenty-shared/workflow';
|
||||
import {
|
||||
type StepIfElseBranch,
|
||||
WorkflowActionType,
|
||||
} from 'twenty-shared/workflow';
|
||||
|
||||
import { type WorkflowCodeActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-settings.type';
|
||||
import { type WorkflowIfElseActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/if-else/types/workflow-if-else-action-settings.type';
|
||||
import { type WorkflowIteratorActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/types/workflow-iterator-action-settings.type';
|
||||
import {
|
||||
WorkflowActionType,
|
||||
type WorkflowCodeAction,
|
||||
type WorkflowIfElseAction,
|
||||
type WorkflowIteratorAction,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
type WorkflowAiAgentAction,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
type WorkflowCodeAction,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
type WorkflowDelayAction,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
WorkflowActionType,
|
||||
type WorkflowAction,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
export const isWorkflowEmptyAction = (
|
||||
action: WorkflowAction,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
type WorkflowFilterAction,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
type WorkflowFormAction,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
|
||||
import { HttpRequestWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/http-request.workflow-action';
|
||||
import { type WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { WorkflowRunStepLogWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run-step-log.workspace-service';
|
||||
|
||||
const baseSettings: WorkflowActionSettings = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
type WorkflowHttpRequestAction,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
type WorkflowIfElseAction,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { StepStatus } from 'twenty-shared/workflow';
|
||||
import { StepStatus, WorkflowActionType } from 'twenty-shared/workflow';
|
||||
|
||||
import { WorkflowStepExecutorException } from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
|
||||
import { IteratorWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/iterator.workflow-action';
|
||||
import { type WorkflowIteratorActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/types/workflow-iterator-action-settings.type';
|
||||
import { type WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service';
|
||||
|
||||
describe('IteratorWorkflowAction', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
type WorkflowIteratorAction,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
export const isWorkflowLogicFunctionAction = (
|
||||
action: WorkflowAction,
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool';
|
||||
import { DraftEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/draft-email.workflow-action';
|
||||
import { type WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { WorkflowRunStepLogWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run-step-log.workspace-service';
|
||||
|
||||
jest.mock(
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool';
|
||||
import { SendEmailWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/send-email.workflow-action';
|
||||
import { type WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { WorkflowRunStepLogWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run-step-log.workspace-service';
|
||||
|
||||
jest.mock(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
type WorkflowDraftEmailAction,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
type WorkflowSendEmailAction,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
type WorkflowDeleteRecordAction,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
type WorkflowFindRecordsAction,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
type WorkflowUpdateRecordAction,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
type WorkflowUpsertRecordAction,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
|
||||
import { type WorkflowAiAgentActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/types/workflow-ai-agent-action-settings.type';
|
||||
import { type WorkflowCodeActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-settings.type';
|
||||
import { type WorkflowDelayActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/delay/types/workflow-delay-action-settings.type';
|
||||
@@ -17,12 +19,6 @@ import {
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-settings.type';
|
||||
import { type WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
|
||||
|
||||
// Import the enum from its dedicated file to avoid circular dependencies
|
||||
import { WorkflowActionType } from './workflow-action-type.enum';
|
||||
|
||||
// Re-export for consumers
|
||||
export { WorkflowActionType };
|
||||
|
||||
type BaseWorkflowAction = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { getWorkflowRunContext, StepStatus } from 'twenty-shared/workflow';
|
||||
import {
|
||||
getWorkflowRunContext,
|
||||
StepStatus,
|
||||
WorkflowActionType,
|
||||
} from 'twenty-shared/workflow';
|
||||
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
@@ -17,10 +21,7 @@ import { WorkflowActionFactory } from 'src/modules/workflow/workflow-executor/fa
|
||||
import { shouldExecuteStep } from 'src/modules/workflow/workflow-executor/utils/should-execute-step.util';
|
||||
import { shouldFailSafely } from 'src/modules/workflow/workflow-executor/utils/should-fail-safely.util';
|
||||
import { shouldSkipStepExecution } from 'src/modules/workflow/workflow-executor/utils/should-skip-step-execution.util';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { WorkflowExecutorWorkspaceService } from 'src/modules/workflow/workflow-executor/workspace-services/workflow-executor.workspace-service';
|
||||
import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service';
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadat
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service';
|
||||
import { WorkflowValidationWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.workspace-service';
|
||||
import { WorkflowVersionEdgeWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-edge/workflow-version-edge.workspace-service';
|
||||
import { WorkflowVersionStepHelpersWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-helpers.workspace-service';
|
||||
import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service';
|
||||
@@ -27,6 +28,7 @@ import { createUpdateLogicFunctionSourceTool } from 'src/modules/workflow/workfl
|
||||
import { createUpdateWorkflowVersionPositionsTool } from 'src/modules/workflow/workflow-tools/tools/update-workflow-version-positions.tool';
|
||||
import { createUpdateWorkflowVersionStepTool } from 'src/modules/workflow/workflow-tools/tools/update-workflow-version-step.tool';
|
||||
import { createUpdateWorkflowVersionTriggerTool } from 'src/modules/workflow/workflow-tools/tools/update-workflow-version-trigger.tool';
|
||||
import { createValidateWorkflowTool } from 'src/modules/workflow/workflow-tools/tools/validate-workflow.tool';
|
||||
import { type WorkflowToolDependencies } from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type';
|
||||
import { WorkflowTriggerWorkspaceService } from 'src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service';
|
||||
|
||||
@@ -41,6 +43,7 @@ export class WorkflowToolWorkspaceService {
|
||||
workflowVersionService: WorkflowVersionWorkspaceService,
|
||||
workflowTriggerService: WorkflowTriggerWorkspaceService,
|
||||
workflowSchemaService: WorkflowSchemaWorkspaceService,
|
||||
workflowValidationService: WorkflowValidationWorkspaceService,
|
||||
globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
recordPositionService: RecordPositionService,
|
||||
logicFunctionFromSourceService: LogicFunctionFromSourceService,
|
||||
@@ -53,6 +56,7 @@ export class WorkflowToolWorkspaceService {
|
||||
workflowVersionService,
|
||||
workflowTriggerService,
|
||||
workflowSchemaService,
|
||||
workflowValidationService,
|
||||
globalWorkspaceOrmManager,
|
||||
recordPositionService,
|
||||
logicFunctionFromSourceService,
|
||||
@@ -124,6 +128,7 @@ export class WorkflowToolWorkspaceService {
|
||||
this.deps,
|
||||
context,
|
||||
);
|
||||
const validateWorkflow = createValidateWorkflowTool(this.deps, context);
|
||||
|
||||
return {
|
||||
[createCompleteWorkflow.name]: createCompleteWorkflow,
|
||||
@@ -141,6 +146,7 @@ export class WorkflowToolWorkspaceService {
|
||||
[getWorkflowCurrentVersion.name]: getWorkflowCurrentVersion,
|
||||
[updateLogicFunctionSource.name]: updateLogicFunctionSource,
|
||||
[listLogicFunctionTools.name]: listLogicFunctionTools,
|
||||
[validateWorkflow.name]: validateWorkflow,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,12 @@ import { z } from 'zod';
|
||||
|
||||
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
import { WorkflowVersionStatus } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
|
||||
import { WorkflowStatus } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
|
||||
import {
|
||||
WorkflowVersionStepException,
|
||||
WorkflowVersionStepExceptionCode,
|
||||
} from 'src/modules/workflow/common/exceptions/workflow-version-step.exception';
|
||||
import { WorkflowVersionStatus } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
|
||||
import { WorkflowStatus } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import {
|
||||
type WorkflowToolContext,
|
||||
@@ -70,6 +70,7 @@ type CreateCompleteWorkflowToolDeps = Pick<
|
||||
| 'workflowTriggerService'
|
||||
| 'globalWorkspaceOrmManager'
|
||||
| 'recordPositionService'
|
||||
| 'workflowValidationService'
|
||||
>;
|
||||
|
||||
type CreateCompleteWorkflowToolContext = WorkflowToolContext & {
|
||||
@@ -182,6 +183,13 @@ This is the most efficient way for AI to create workflows as it handles all the
|
||||
});
|
||||
}
|
||||
|
||||
const validation =
|
||||
await deps.workflowValidationService.validateWorkflowDefinition({
|
||||
workspaceId: context.workspaceId,
|
||||
trigger: parameters.trigger,
|
||||
steps: parameters.steps,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Workflow "${parameters.name}" created successfully with ${parameters.steps.length} steps`,
|
||||
@@ -191,6 +199,7 @@ This is the most efficient way for AI to create workflows as it handles all the
|
||||
name: parameters.name,
|
||||
trigger: parameters.trigger,
|
||||
steps: parameters.steps,
|
||||
validation,
|
||||
},
|
||||
recordReferences: [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum';
|
||||
import {
|
||||
type WorkflowToolContext,
|
||||
type WorkflowToolDependencies,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
|
||||
import { TRIGGER_STEP_ID, WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step.input';
|
||||
import { type WorkflowVersionStepChangesDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto';
|
||||
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum';
|
||||
import {
|
||||
type WorkflowToolContext,
|
||||
type WorkflowToolDependencies,
|
||||
@@ -85,7 +84,7 @@ const enrichResultWithNextStep = ({
|
||||
return {
|
||||
...result,
|
||||
nextStep:
|
||||
'This CODE step was created with a default placeholder function. You MUST now call update_logic_function_source with the logicFunctionId from this step to define the actual code.',
|
||||
'This CODE step was created with a default placeholder function. You MUST now call update_logic_function_source with the logicFunctionId from this step to define the actual code. IMPORTANT: Also provide outputSchema (an example return value, e.g. { datePlus7: "2026-06-16" }) so downstream steps can reference this step\'s output variables via {{stepId.fieldName}}.',
|
||||
};
|
||||
default:
|
||||
return result;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum';
|
||||
import {
|
||||
type WorkflowToolContext,
|
||||
type WorkflowToolDependencies,
|
||||
|
||||
@@ -18,7 +18,10 @@ const updateWorkflowVersionStepSchema = z.object({
|
||||
});
|
||||
|
||||
export const createUpdateWorkflowVersionStepTool = (
|
||||
deps: Pick<WorkflowToolDependencies, 'workflowVersionStepService'>,
|
||||
deps: Pick<
|
||||
WorkflowToolDependencies,
|
||||
'workflowVersionStepService' | 'workflowValidationService'
|
||||
>,
|
||||
context: WorkflowToolContext,
|
||||
) => ({
|
||||
name: 'update_workflow_version_step' as const,
|
||||
@@ -26,8 +29,10 @@ export const createUpdateWorkflowVersionStepTool = (
|
||||
'Update an existing step in a workflow version. This modifies the step configuration.',
|
||||
inputSchema: updateWorkflowVersionStepSchema,
|
||||
execute: async (parameters: UpdateWorkflowVersionStepInput) => {
|
||||
let result;
|
||||
|
||||
try {
|
||||
return await deps.workflowVersionStepService.updateWorkflowVersionStep({
|
||||
result = await deps.workflowVersionStepService.updateWorkflowVersionStep({
|
||||
workspaceId: context.workspaceId,
|
||||
workflowVersionId: parameters.workflowVersionId,
|
||||
step: parameters.step,
|
||||
@@ -39,5 +44,23 @@ export const createUpdateWorkflowVersionStepTool = (
|
||||
message: `Failed to update workflow version step: ${error.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const validation =
|
||||
await deps.workflowValidationService.validateWorkflowVersion({
|
||||
workspaceId: context.workspaceId,
|
||||
workflowVersionId: parameters.workflowVersionId,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
validation,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...result,
|
||||
validationError: error.message,
|
||||
message: `Step updated successfully, but validation could not be computed: ${error.message}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type WorkflowValidationWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.workspace-service';
|
||||
import { type WorkflowToolContext } from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type';
|
||||
|
||||
const validateWorkflowSchema = z.object({
|
||||
workflowVersionId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe('The UUID of the workflow version to validate'),
|
||||
});
|
||||
|
||||
type ValidateWorkflowInput = z.infer<typeof validateWorkflowSchema>;
|
||||
|
||||
export const createValidateWorkflowTool = (
|
||||
deps: {
|
||||
workflowValidationService: WorkflowValidationWorkspaceService;
|
||||
},
|
||||
context: WorkflowToolContext,
|
||||
) => ({
|
||||
name: 'validate_workflow' as const,
|
||||
description:
|
||||
'Validate a workflow version for correctness. Checks graph topology (connections, reachability, branches, loops), per-step configuration, references to other objects, and variable references between steps. Returns a list of errors and warnings to fix. Does not block or modify the workflow.',
|
||||
inputSchema: validateWorkflowSchema,
|
||||
execute: async (parameters: ValidateWorkflowInput) => {
|
||||
try {
|
||||
const result =
|
||||
await deps.workflowValidationService.validateWorkflowVersion({
|
||||
workspaceId: context.workspaceId,
|
||||
workflowVersionId: parameters.workflowVersionId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
valid: result.valid,
|
||||
errors: result.errors,
|
||||
warnings: result.warnings,
|
||||
message: result.valid
|
||||
? 'The workflow is valid.'
|
||||
: `The workflow has ${result.errors.length} error(s) that should be fixed.`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
message: `Failed to validate workflow: ${error.message}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import type { LogicFunctionFromSourceService } from 'src/engine/metadata-modules
|
||||
import type { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import type { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import type { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service';
|
||||
import type { WorkflowValidationWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.workspace-service';
|
||||
import type { WorkflowVersionEdgeWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-edge/workflow-version-edge.workspace-service';
|
||||
import type { WorkflowVersionStepHelpersWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-helpers.workspace-service';
|
||||
import type { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service';
|
||||
@@ -16,6 +17,7 @@ export type WorkflowToolDependencies = {
|
||||
workflowVersionService: WorkflowVersionWorkspaceService;
|
||||
workflowTriggerService: WorkflowTriggerWorkspaceService;
|
||||
workflowSchemaService: WorkflowSchemaWorkspaceService;
|
||||
workflowValidationService: WorkflowValidationWorkspaceService;
|
||||
globalWorkspaceOrmManager: GlobalWorkspaceOrmManager;
|
||||
recordPositionService: RecordPositionService;
|
||||
logicFunctionFromSourceService: LogicFunctionFromSourceService;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { WORKFLOW_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provid
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module';
|
||||
import { WorkflowSchemaModule } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.module';
|
||||
import { WorkflowValidationModule } from 'src/modules/workflow/workflow-builder/workflow-validation/workflow-validation.module';
|
||||
import { WorkflowVersionEdgeModule } from 'src/modules/workflow/workflow-builder/workflow-version-edge/workflow-version-edge.module';
|
||||
import { WorkflowVersionStepModule } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.module';
|
||||
import { WorkflowVersionModule } from 'src/modules/workflow/workflow-builder/workflow-version/workflow-version.module';
|
||||
@@ -22,6 +23,7 @@ import { WorkflowToolWorkspaceService } from './services/workflow-tool.workspace
|
||||
WorkflowVersionModule,
|
||||
WorkflowTriggerModule,
|
||||
WorkflowSchemaModule,
|
||||
WorkflowValidationModule,
|
||||
RecordPositionModule,
|
||||
LogicFunctionModule,
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
|
||||
@@ -4,11 +4,9 @@ import {
|
||||
WorkflowVersionStatus,
|
||||
type WorkflowVersionWorkspaceEntity,
|
||||
} from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
|
||||
import { WorkflowActionType } from 'twenty-shared/workflow';
|
||||
import { type WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
|
||||
import {
|
||||
type WorkflowAction,
|
||||
WorkflowActionType,
|
||||
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import {
|
||||
WorkflowTriggerException,
|
||||
WorkflowTriggerExceptionCode,
|
||||
|
||||
@@ -79,6 +79,7 @@ export type {
|
||||
InputSchema,
|
||||
} from './types/InputSchema';
|
||||
export type { StepIfElseBranch } from './types/StepIfElseBranch';
|
||||
export { WorkflowActionType } from './types/WorkflowActionType';
|
||||
export type { WorkflowAttachment } from './types/WorkflowAttachment';
|
||||
export type { BodyType } from './types/workflowHttpRequestStep';
|
||||
export type {
|
||||
@@ -104,6 +105,33 @@ export {
|
||||
joinVariablePath,
|
||||
parseVariablePath,
|
||||
} from './utils/variable-path.util';
|
||||
export { isIfElseStepInput } from './validation/guards/isIfElseStepInput';
|
||||
export { isIteratorStepInput } from './validation/guards/isIteratorStepInput';
|
||||
export type {
|
||||
IfElseStepInput,
|
||||
IteratorStepInput,
|
||||
WorkflowValidationSeverity,
|
||||
WorkflowValidationIssueCode,
|
||||
WorkflowValidationIssue,
|
||||
WorkflowValidationResult,
|
||||
ValidatableWorkflowStep,
|
||||
ValidatableWorkflowTrigger,
|
||||
ValidatableWorkflow,
|
||||
} from './validation/types/workflow-validation.type';
|
||||
export type { WorkflowGraph } from './validation/utils/build-workflow-graph.util';
|
||||
export { buildWorkflowGraph } from './validation/utils/build-workflow-graph.util';
|
||||
export { extractVariablesFromInput } from './validation/utils/extract-variables-from-input.util';
|
||||
export { getEditDistance } from './validation/utils/get-edit-distance.util';
|
||||
export {
|
||||
getStepInput,
|
||||
getStepOutgoingStepIds,
|
||||
} from './validation/utils/get-step-outgoing-step-ids.util';
|
||||
export { getVariablePathSuggestions } from './validation/utils/get-variable-path-suggestions.util';
|
||||
export { validateWorkflowGraph } from './validation/utils/validate-workflow-graph.util';
|
||||
export { validateWorkflowStepParams } from './validation/utils/validate-workflow-step-params.util';
|
||||
export { validateWorkflowVariableReferences } from './validation/utils/validate-workflow-variable-references.util';
|
||||
export { validateWorkflowStructure } from './validation/validate-workflow-structure.util';
|
||||
export { isBaseOutputSchemaV2 } from './workflow-schema/guards/isBaseOutputSchemaV2';
|
||||
export type {
|
||||
LeafType,
|
||||
NodeType,
|
||||
@@ -111,7 +139,38 @@ export type {
|
||||
Node,
|
||||
BaseOutputSchemaV2,
|
||||
} from './workflow-schema/types/base-output-schema.type';
|
||||
export { navigateOutputSchemaProperty } from './workflow-schema/utils/navigateOutputSchemaProperty';
|
||||
export type {
|
||||
RecordFieldLeaf,
|
||||
RecordFieldNode,
|
||||
RecordFieldNodeValue,
|
||||
FieldOutputSchemaV2,
|
||||
RecordOutputSchemaV2,
|
||||
RecordNode,
|
||||
FindRecordsOutputSchema,
|
||||
IteratorOutputSchema,
|
||||
FormFieldLeaf,
|
||||
FormFieldNode,
|
||||
FormOutputSchema,
|
||||
LinkOutputSchema,
|
||||
CodeOutputSchema,
|
||||
ManualTriggerOutputSchema,
|
||||
OutputSchemaV2,
|
||||
VariableSearchResult,
|
||||
} from './workflow-schema/types/output-schema.type';
|
||||
export { collectOutputSchemaPaths } from './workflow-schema/utils/collect-output-schema-paths';
|
||||
export type { OutputSchemaPathFailure } from './workflow-schema/utils/find-output-schema-path-failure';
|
||||
export { findOutputSchemaPathFailure } from './workflow-schema/utils/find-output-schema-path-failure';
|
||||
export { navigateOutputSchemaProperty } from './workflow-schema/utils/navigate-output-schema-property';
|
||||
export type { ResolvedVariable } from './workflow-schema/utils/resolve-variable-path-in-output-schema';
|
||||
export {
|
||||
resolveInSchema,
|
||||
resolveVariablePathInOutputSchema,
|
||||
collectOutputSchemaVariablePaths,
|
||||
} from './workflow-schema/utils/resolve-variable-path-in-output-schema';
|
||||
export {
|
||||
searchRecordOutputSchema,
|
||||
searchVariableInOutputSchema,
|
||||
} from './workflow-schema/utils/search-variable-in-output-schema';
|
||||
export type {
|
||||
GlobalAvailability,
|
||||
SingleRecordAvailability,
|
||||
|
||||
@@ -2,9 +2,9 @@ import { z } from 'zod';
|
||||
|
||||
export const baseWorkflowActionSchema = z.object({
|
||||
id: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe(
|
||||
'Unique identifier for the workflow step. Must be unique within the workflow.',
|
||||
'Unique UUID identifier for the workflow step. Must be a valid UUID v4, unique within the workflow.',
|
||||
),
|
||||
name: z
|
||||
.string()
|
||||
@@ -17,7 +17,7 @@ export const baseWorkflowActionSchema = z.object({
|
||||
'Whether the step configuration is valid. Set to true when all required fields are properly configured.',
|
||||
),
|
||||
nextStepIds: z
|
||||
.array(z.string())
|
||||
.array(z.uuid())
|
||||
.optional()
|
||||
.nullable()
|
||||
.describe(
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { type ValidatableWorkflow } from '@/workflow/validation/types/workflow-validation.type';
|
||||
import { validateWorkflowStructure } from '../validate-workflow-structure.util';
|
||||
|
||||
const getCodes = (workflow: ValidatableWorkflow): string[] => {
|
||||
const result = validateWorkflowStructure(workflow);
|
||||
|
||||
return [...result.errors, ...result.warnings].map((issue) => issue.code);
|
||||
};
|
||||
|
||||
describe('validateWorkflowStructure', () => {
|
||||
it('should flag a missing trigger and missing steps', () => {
|
||||
const result = validateWorkflowStructure({
|
||||
trigger: undefined,
|
||||
steps: [],
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.map((issue) => issue.code)).toEqual(
|
||||
expect.arrayContaining(['MISSING_TRIGGER', 'NO_STEPS']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should flag a trigger without a type', () => {
|
||||
expect(getCodes({ trigger: {}, steps: [] })).toContain(
|
||||
'MISSING_TRIGGER_TYPE',
|
||||
);
|
||||
});
|
||||
|
||||
it('should flag a workflow without steps', () => {
|
||||
expect(getCodes({ trigger: { type: 'MANUAL' }, steps: [] })).toContain(
|
||||
'NO_STEPS',
|
||||
);
|
||||
});
|
||||
|
||||
it('should aggregate graph issues such as unreachable steps', () => {
|
||||
const result = validateWorkflowStructure({
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1'] },
|
||||
steps: [
|
||||
{ id: 's1', type: 'CODE' },
|
||||
{ id: 'orphan', type: 'CODE' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.map((issue) => issue.code)).toContain(
|
||||
'UNREACHABLE_STEP',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { isObject } from '@sniptt/guards';
|
||||
|
||||
import { WorkflowActionType } from '@/workflow/types/WorkflowActionType';
|
||||
import {
|
||||
type IfElseStepInput,
|
||||
type ValidatableWorkflowStep,
|
||||
} from '@/workflow/validation/types/workflow-validation.type';
|
||||
|
||||
export const isIfElseStepInput = (
|
||||
step: ValidatableWorkflowStep,
|
||||
): step is ValidatableWorkflowStep & {
|
||||
settings: { input: Partial<IfElseStepInput> };
|
||||
} => {
|
||||
const input = step.settings?.input;
|
||||
|
||||
return (
|
||||
step.type === WorkflowActionType.IF_ELSE &&
|
||||
isObject(input) &&
|
||||
'branches' in input &&
|
||||
Array.isArray(input.branches)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { isNonEmptyArray, isObject } from '@sniptt/guards';
|
||||
|
||||
import { WorkflowActionType } from '@/workflow/types/WorkflowActionType';
|
||||
import {
|
||||
type IteratorStepInput,
|
||||
type ValidatableWorkflowStep,
|
||||
} from '@/workflow/validation/types/workflow-validation.type';
|
||||
|
||||
export const isIteratorStepInput = (
|
||||
step: ValidatableWorkflowStep,
|
||||
): step is ValidatableWorkflowStep & {
|
||||
settings: { input: Partial<IteratorStepInput> };
|
||||
} => {
|
||||
const input = step.settings?.input;
|
||||
|
||||
return (
|
||||
step.type === WorkflowActionType.ITERATOR &&
|
||||
isObject(input) &&
|
||||
'initialLoopStepIds' in input &&
|
||||
isNonEmptyArray(input.initialLoopStepIds)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { type workflowIfElseActionSettingsSchema } from '@/workflow/schemas/if-else-action-settings-schema';
|
||||
import { type workflowIteratorActionSettingsSchema } from '@/workflow/schemas/iterator-action-settings-schema';
|
||||
import { type z } from 'zod';
|
||||
|
||||
export type IfElseStepInput = z.infer<
|
||||
typeof workflowIfElseActionSettingsSchema
|
||||
>['input'];
|
||||
export type IteratorStepInput = z.infer<
|
||||
typeof workflowIteratorActionSettingsSchema
|
||||
>['input'];
|
||||
|
||||
export type WorkflowValidationSeverity = 'error' | 'warning';
|
||||
|
||||
export type WorkflowValidationIssueCode =
|
||||
| 'MISSING_TRIGGER'
|
||||
| 'MISSING_TRIGGER_TYPE'
|
||||
| 'NO_STEPS'
|
||||
| 'TRIGGER_HAS_NO_NEXT_STEP'
|
||||
| 'DUPLICATE_STEP_ID'
|
||||
| 'DANGLING_REFERENCE'
|
||||
| 'UNREACHABLE_STEP'
|
||||
| 'INVALID_TRIGGER_PARAMS'
|
||||
| 'INVALID_STEP_PARAMS'
|
||||
| 'IF_ELSE_INSUFFICIENT_BRANCHES'
|
||||
| 'IF_ELSE_BRANCH_HAS_NO_NEXT_STEP'
|
||||
| 'ITERATOR_MISSING_LOOP_BODY'
|
||||
| 'VARIABLE_INVALID_PATH'
|
||||
| 'VARIABLE_UNKNOWN_STEP'
|
||||
| 'VARIABLE_NOT_UPSTREAM'
|
||||
| 'VARIABLE_MISSING_OUTPUT_SCHEMA'
|
||||
| 'VARIABLE_PATH_NOT_FOUND'
|
||||
| 'CODE_STEP_MISSING_OUTPUT_SCHEMA'
|
||||
| 'AI_AGENT_MISSING_AGENT'
|
||||
| 'AI_AGENT_MISSING_OUTPUT_VARIABLE';
|
||||
|
||||
export type WorkflowValidationIssue = {
|
||||
severity: WorkflowValidationSeverity;
|
||||
code: WorkflowValidationIssueCode;
|
||||
message: string;
|
||||
stepId?: string;
|
||||
path?: string;
|
||||
hint?: string;
|
||||
suggestions?: string[];
|
||||
availablePaths?: string[];
|
||||
};
|
||||
|
||||
export type WorkflowValidationResult = {
|
||||
valid: boolean;
|
||||
errors: WorkflowValidationIssue[];
|
||||
warnings: WorkflowValidationIssue[];
|
||||
};
|
||||
|
||||
export type ValidatableWorkflowStep = {
|
||||
id: string;
|
||||
name?: string;
|
||||
type: string;
|
||||
valid?: boolean;
|
||||
nextStepIds?: string[] | null;
|
||||
settings?: {
|
||||
input?: unknown;
|
||||
outputSchema?: unknown;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type ValidatableWorkflowTrigger = {
|
||||
type?: string;
|
||||
name?: string;
|
||||
settings?: {
|
||||
input?: unknown;
|
||||
outputSchema?: unknown;
|
||||
} | null;
|
||||
nextStepIds?: string[] | null;
|
||||
};
|
||||
|
||||
export type ValidatableWorkflow = {
|
||||
trigger: ValidatableWorkflowTrigger | null | undefined;
|
||||
steps: ValidatableWorkflowStep[] | null | undefined;
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId';
|
||||
import {
|
||||
type ValidatableWorkflow,
|
||||
type ValidatableWorkflowStep,
|
||||
} from '@/workflow/validation/types/workflow-validation.type';
|
||||
import { buildWorkflowGraph } from '../build-workflow-graph.util';
|
||||
|
||||
const buildStep = (
|
||||
id: string,
|
||||
nextStepIds: string[] = [],
|
||||
): ValidatableWorkflowStep => ({ id, type: 'CODE', nextStepIds });
|
||||
|
||||
const ancestorsOf = (
|
||||
graph: ReturnType<typeof buildWorkflowGraph>,
|
||||
stepId: string,
|
||||
): string[] => [...(graph.ancestorsByStepId.get(stepId) ?? [])];
|
||||
|
||||
describe('buildWorkflowGraph', () => {
|
||||
it('should map trigger children, reachability and ancestors for a linear flow', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1'] },
|
||||
steps: [buildStep('s1', ['s2']), buildStep('s2')],
|
||||
};
|
||||
|
||||
const graph = buildWorkflowGraph(workflow);
|
||||
|
||||
expect(graph.childrenByStepId.get(TRIGGER_STEP_ID)).toEqual(['s1']);
|
||||
expect(graph.reachableFromTrigger.has('s1')).toBe(true);
|
||||
expect(graph.reachableFromTrigger.has('s2')).toBe(true);
|
||||
expect(ancestorsOf(graph, 's1')).toEqual([TRIGGER_STEP_ID]);
|
||||
expect(ancestorsOf(graph, 's2')).toEqual(
|
||||
expect.arrayContaining([TRIGGER_STEP_ID, 's1']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not mark disconnected steps as reachable', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1'] },
|
||||
steps: [buildStep('s1'), buildStep('orphan')],
|
||||
};
|
||||
|
||||
const graph = buildWorkflowGraph(workflow);
|
||||
|
||||
expect(graph.reachableFromTrigger.has('s1')).toBe(true);
|
||||
expect(graph.reachableFromTrigger.has('orphan')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle a trigger without nextStepIds', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL' },
|
||||
steps: [buildStep('s1')],
|
||||
};
|
||||
|
||||
const graph = buildWorkflowGraph(workflow);
|
||||
|
||||
expect(graph.childrenByStepId.get(TRIGGER_STEP_ID)).toEqual([]);
|
||||
expect(graph.reachableFromTrigger.has('s1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not loop forever on cyclic graphs', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1'] },
|
||||
steps: [buildStep('s1', ['s2']), buildStep('s2', ['s1'])],
|
||||
};
|
||||
|
||||
const graph = buildWorkflowGraph(workflow);
|
||||
|
||||
expect(graph.reachableFromTrigger.has('s1')).toBe(true);
|
||||
expect(graph.reachableFromTrigger.has('s2')).toBe(true);
|
||||
expect(ancestorsOf(graph, 's1')).toEqual(
|
||||
expect.arrayContaining([TRIGGER_STEP_ID, 's1', 's2']),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { extractVariablesFromInput } from '../extract-variables-from-input.util';
|
||||
|
||||
describe('extractVariablesFromInput', () => {
|
||||
it('should return an empty array for non-string, non-object input', () => {
|
||||
expect(extractVariablesFromInput(undefined)).toEqual([]);
|
||||
expect(extractVariablesFromInput(null)).toEqual([]);
|
||||
expect(extractVariablesFromInput(42)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should extract a single variable from a string', () => {
|
||||
expect(extractVariablesFromInput('Hello {{step1.name}}')).toEqual([
|
||||
'step1.name',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should extract every variable across nested objects and arrays', () => {
|
||||
const input = {
|
||||
greeting: 'Hi {{trigger.email}}',
|
||||
nested: { message: 'X {{step2.value}} Y {{step3.id}}' },
|
||||
list: ['{{step4.foo}}'],
|
||||
};
|
||||
|
||||
expect(extractVariablesFromInput(input)).toEqual([
|
||||
'trigger.email',
|
||||
'step2.value',
|
||||
'step3.id',
|
||||
'step4.foo',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return an empty array when no variables are present', () => {
|
||||
expect(extractVariablesFromInput({ a: 'plain text', b: 5 })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getEditDistance } from '../get-edit-distance.util';
|
||||
|
||||
describe('getEditDistance', () => {
|
||||
it('should return 0 for identical strings', () => {
|
||||
expect(getEditDistance('name', 'name')).toBe(0);
|
||||
});
|
||||
|
||||
it('should count a single deletion', () => {
|
||||
expect(getEditDistance('name', 'nme')).toBe(1);
|
||||
});
|
||||
|
||||
it('should count a single insertion', () => {
|
||||
expect(getEditDistance('nme', 'name')).toBe(1);
|
||||
});
|
||||
|
||||
it('should count a single substitution', () => {
|
||||
expect(getEditDistance('firstName', 'firstname')).toBe(1);
|
||||
});
|
||||
|
||||
it('should compute the classic kitten/sitting distance', () => {
|
||||
expect(getEditDistance('kitten', 'sitting')).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
expect(getEditDistance('', 'name')).toBe(4);
|
||||
expect(getEditDistance('name', '')).toBe(4);
|
||||
expect(getEditDistance('', '')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { WorkflowActionType } from '@/workflow/types/WorkflowActionType';
|
||||
import { type ValidatableWorkflowStep } from '@/workflow/validation/types/workflow-validation.type';
|
||||
import {
|
||||
getStepInput,
|
||||
getStepOutgoingStepIds,
|
||||
} from '../get-step-outgoing-step-ids.util';
|
||||
|
||||
describe('getStepInput', () => {
|
||||
it('should return the input object when present', () => {
|
||||
const step: ValidatableWorkflowStep = {
|
||||
id: 'step-1',
|
||||
type: 'CODE',
|
||||
settings: { input: { foo: 'bar' } },
|
||||
};
|
||||
|
||||
expect(getStepInput(step)).toEqual({ foo: 'bar' });
|
||||
});
|
||||
|
||||
it('should return undefined when input is missing or not an object', () => {
|
||||
expect(getStepInput({ id: 'step-1', type: 'CODE' })).toBeUndefined();
|
||||
expect(
|
||||
getStepInput({ id: 'step-1', type: 'CODE', settings: { input: 'text' } }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStepOutgoingStepIds', () => {
|
||||
it('should return the nextStepIds of a regular step', () => {
|
||||
expect(
|
||||
getStepOutgoingStepIds({
|
||||
id: 'step-1',
|
||||
type: 'CODE',
|
||||
nextStepIds: ['a', 'b'],
|
||||
}),
|
||||
).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('should deduplicate outgoing step ids', () => {
|
||||
expect(
|
||||
getStepOutgoingStepIds({
|
||||
id: 'step-1',
|
||||
type: 'CODE',
|
||||
nextStepIds: ['a', 'a'],
|
||||
}),
|
||||
).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('should include if-else branch nextStepIds', () => {
|
||||
const step: ValidatableWorkflowStep = {
|
||||
id: 'step-1',
|
||||
type: WorkflowActionType.IF_ELSE,
|
||||
nextStepIds: ['x'],
|
||||
settings: {
|
||||
input: { branches: [{ nextStepIds: ['b1'] }, { nextStepIds: ['b2'] }] },
|
||||
},
|
||||
};
|
||||
|
||||
expect(getStepOutgoingStepIds(step).sort()).toEqual(['b1', 'b2', 'x']);
|
||||
});
|
||||
|
||||
it('should include iterator initialLoopStepIds', () => {
|
||||
const step: ValidatableWorkflowStep = {
|
||||
id: 'step-1',
|
||||
type: WorkflowActionType.ITERATOR,
|
||||
settings: { input: { initialLoopStepIds: ['loop-1'] } },
|
||||
};
|
||||
|
||||
expect(getStepOutgoingStepIds(step)).toEqual(['loop-1']);
|
||||
});
|
||||
|
||||
it('should fall back to nextStepIds when input is not an object', () => {
|
||||
expect(
|
||||
getStepOutgoingStepIds({
|
||||
id: 'step-1',
|
||||
type: 'CODE',
|
||||
nextStepIds: ['a'],
|
||||
settings: { input: undefined },
|
||||
}),
|
||||
).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { type BaseOutputSchemaV2 } from '@/workflow/workflow-schema/types/base-output-schema.type';
|
||||
|
||||
import { getVariablePathSuggestions } from '../get-variable-path-suggestions.util';
|
||||
|
||||
describe('getVariablePathSuggestions', () => {
|
||||
const schema: BaseOutputSchemaV2 = {
|
||||
user: {
|
||||
isLeaf: false,
|
||||
type: 'object',
|
||||
label: 'user',
|
||||
value: {
|
||||
name: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'name',
|
||||
value: 'Test',
|
||||
},
|
||||
email: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'email',
|
||||
value: 'test@test.com',
|
||||
},
|
||||
},
|
||||
},
|
||||
id: {
|
||||
isLeaf: true,
|
||||
type: 'string',
|
||||
label: 'id',
|
||||
value: '1',
|
||||
},
|
||||
};
|
||||
|
||||
it('should suggest the closest sibling for a nested typo (strategy A)', () => {
|
||||
expect(
|
||||
getVariablePathSuggestions({
|
||||
schema,
|
||||
propertyPath: ['user', 'naem'],
|
||||
referencedStepId: 'step-1',
|
||||
}),
|
||||
).toEqual(['step-1.user.name']);
|
||||
});
|
||||
|
||||
it('should suggest a structurally correct path when the prefix is wrong (strategy B)', () => {
|
||||
expect(
|
||||
getVariablePathSuggestions({
|
||||
schema,
|
||||
propertyPath: ['name'],
|
||||
referencedStepId: 'step-1',
|
||||
}),
|
||||
).toContain('step-1.user.name');
|
||||
});
|
||||
|
||||
it('should return no suggestions for an unrelated segment', () => {
|
||||
expect(
|
||||
getVariablePathSuggestions({
|
||||
schema,
|
||||
propertyPath: ['completelyDifferentThing'],
|
||||
referencedStepId: 'step-1',
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return no suggestions when the path resolves', () => {
|
||||
expect(
|
||||
getVariablePathSuggestions({
|
||||
schema,
|
||||
propertyPath: ['user', 'name'],
|
||||
referencedStepId: 'step-1',
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('should suggest record field names (not internal keys) for FIND_RECORDS schemas', () => {
|
||||
const findRecordsSchema = {
|
||||
first: {
|
||||
isLeaf: false,
|
||||
label: 'First',
|
||||
value: {
|
||||
object: {
|
||||
objectMetadataId: 'company-metadata-id',
|
||||
label: 'Company',
|
||||
},
|
||||
fields: {
|
||||
name: {
|
||||
isLeaf: true,
|
||||
type: 'TEXT',
|
||||
label: 'Company Name',
|
||||
value: 'Acme Corp',
|
||||
fieldMetadataId: 'company-name-metadata-id',
|
||||
isCompositeSubField: false,
|
||||
},
|
||||
revenue: {
|
||||
isLeaf: true,
|
||||
type: 'NUMBER',
|
||||
label: 'Revenue',
|
||||
value: 1000000,
|
||||
fieldMetadataId: 'company-revenue-metadata-id',
|
||||
isCompositeSubField: false,
|
||||
},
|
||||
},
|
||||
_outputSchemaType: 'RECORD',
|
||||
},
|
||||
},
|
||||
all: {
|
||||
isLeaf: true,
|
||||
label: 'All',
|
||||
value: 'Returns an array of records',
|
||||
type: 'array',
|
||||
},
|
||||
totalCount: {
|
||||
isLeaf: true,
|
||||
label: 'Total Count',
|
||||
value: 42,
|
||||
type: 'number',
|
||||
},
|
||||
};
|
||||
|
||||
const suggestions = getVariablePathSuggestions({
|
||||
schema: findRecordsSchema,
|
||||
propertyPath: ['first', 'naem'],
|
||||
referencedStepId: 'step-1',
|
||||
});
|
||||
|
||||
expect(suggestions).toContain('step-1.first.name');
|
||||
expect(
|
||||
suggestions.some(
|
||||
(suggestion) =>
|
||||
suggestion.includes('object') ||
|
||||
suggestion.includes('fields') ||
|
||||
suggestion.includes('_outputSchemaType'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should suggest record field names (not internal keys) for FORM schemas', () => {
|
||||
const formSchema = {
|
||||
companyName: {
|
||||
isLeaf: true,
|
||||
type: 'TEXT',
|
||||
label: 'Company Name',
|
||||
value: 'Acme Corp',
|
||||
},
|
||||
person: {
|
||||
isLeaf: false,
|
||||
label: 'Person',
|
||||
value: {
|
||||
object: {
|
||||
objectMetadataId: 'person-metadata-id',
|
||||
label: 'Person',
|
||||
},
|
||||
fields: {
|
||||
firstName: {
|
||||
isLeaf: true,
|
||||
type: 'TEXT',
|
||||
label: 'First Name',
|
||||
value: 'John',
|
||||
fieldMetadataId: 'person-firstName-metadata-id',
|
||||
isCompositeSubField: false,
|
||||
},
|
||||
},
|
||||
_outputSchemaType: 'RECORD',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const suggestions = getVariablePathSuggestions({
|
||||
schema: formSchema,
|
||||
propertyPath: ['person', 'firstNaem'],
|
||||
referencedStepId: 'step-1',
|
||||
});
|
||||
|
||||
expect(suggestions).toContain('step-1.person.firstName');
|
||||
expect(
|
||||
suggestions.some(
|
||||
(suggestion) =>
|
||||
suggestion.includes('object') ||
|
||||
suggestion.includes('fields') ||
|
||||
suggestion.includes('_outputSchemaType'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { WorkflowActionType } from '@/workflow/types/WorkflowActionType';
|
||||
import {
|
||||
type ValidatableWorkflow,
|
||||
type WorkflowValidationIssueCode,
|
||||
} from '@/workflow/validation/types/workflow-validation.type';
|
||||
import { buildWorkflowGraph } from '../build-workflow-graph.util';
|
||||
import { validateWorkflowGraph } from '../validate-workflow-graph.util';
|
||||
|
||||
const getCodes = (
|
||||
workflow: ValidatableWorkflow,
|
||||
): WorkflowValidationIssueCode[] =>
|
||||
validateWorkflowGraph({
|
||||
workflow,
|
||||
graph: buildWorkflowGraph(workflow),
|
||||
}).map((issue) => issue.code);
|
||||
|
||||
describe('validateWorkflowGraph', () => {
|
||||
it('should return no issues for a valid linear workflow', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1'] },
|
||||
steps: [{ id: 's1', type: 'CODE', nextStepIds: [] }],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should flag duplicate step ids', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1'] },
|
||||
steps: [
|
||||
{ id: 's1', type: 'CODE' },
|
||||
{ id: 's1', type: 'CODE' },
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toContain('DUPLICATE_STEP_ID');
|
||||
});
|
||||
|
||||
it('should flag a trigger with no connected step', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL' },
|
||||
steps: [{ id: 's1', type: 'CODE' }],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toContain('TRIGGER_HAS_NO_NEXT_STEP');
|
||||
});
|
||||
|
||||
it('should flag a dangling trigger reference', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['ghost'] },
|
||||
steps: [{ id: 's1', type: 'CODE' }],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toContain('DANGLING_REFERENCE');
|
||||
});
|
||||
|
||||
it('should flag unreachable steps', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1'] },
|
||||
steps: [
|
||||
{ id: 's1', type: 'CODE' },
|
||||
{ id: 'orphan', type: 'CODE' },
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toContain('UNREACHABLE_STEP');
|
||||
});
|
||||
|
||||
it('should flag an if-else step with fewer than two branches', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['if'] },
|
||||
steps: [
|
||||
{
|
||||
id: 'if',
|
||||
type: WorkflowActionType.IF_ELSE,
|
||||
settings: { input: { branches: [{ nextStepIds: ['end'] }] } },
|
||||
},
|
||||
{ id: 'end', type: 'CODE' },
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toContain('IF_ELSE_INSUFFICIENT_BRANCHES');
|
||||
});
|
||||
|
||||
it('should flag an if-else branch with no connected step', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['if'] },
|
||||
steps: [
|
||||
{
|
||||
id: 'if',
|
||||
type: WorkflowActionType.IF_ELSE,
|
||||
settings: {
|
||||
input: {
|
||||
branches: [{ nextStepIds: ['end'] }, { nextStepIds: [] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: 'end', type: 'CODE' },
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toContain('IF_ELSE_BRANCH_HAS_NO_NEXT_STEP');
|
||||
});
|
||||
|
||||
it('should flag an iterator with items but no loop body', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['iterator'] },
|
||||
steps: [
|
||||
{
|
||||
id: 'iterator',
|
||||
type: WorkflowActionType.ITERATOR,
|
||||
settings: {
|
||||
input: { items: '{{trigger.items}}', initialLoopStepIds: [] },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toContain('ITERATOR_MISSING_LOOP_BODY');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { type ValidatableWorkflow } from '@/workflow/validation/types/workflow-validation.type';
|
||||
import { validateWorkflowStepParams } from '../validate-workflow-step-params.util';
|
||||
|
||||
describe('validateWorkflowStepParams', () => {
|
||||
it('should return no issues when there is no trigger and no steps', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: undefined,
|
||||
steps: undefined,
|
||||
};
|
||||
|
||||
expect(validateWorkflowStepParams(workflow)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should flag an invalid trigger configuration', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'NOT_A_REAL_TRIGGER' },
|
||||
steps: [],
|
||||
};
|
||||
|
||||
const issues = validateWorkflowStepParams(workflow);
|
||||
|
||||
expect(
|
||||
issues.some((issue) => issue.code === 'INVALID_TRIGGER_PARAMS'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should flag an invalid step configuration with its step id', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: undefined,
|
||||
steps: [{ id: 'step-1', type: 'NOT_A_REAL_ACTION' }],
|
||||
};
|
||||
|
||||
const issues = validateWorkflowStepParams(workflow);
|
||||
|
||||
expect(
|
||||
issues.some(
|
||||
(issue) =>
|
||||
issue.code === 'INVALID_STEP_PARAMS' && issue.stepId === 'step-1',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import {
|
||||
type ValidatableWorkflow,
|
||||
type ValidatableWorkflowStep,
|
||||
type WorkflowValidationIssueCode,
|
||||
} from '@/workflow/validation/types/workflow-validation.type';
|
||||
import { buildWorkflowGraph } from '../build-workflow-graph.util';
|
||||
import { validateWorkflowVariableReferences } from '../validate-workflow-variable-references.util';
|
||||
|
||||
const OUTPUT_SCHEMA = {
|
||||
name: { isLeaf: true, type: 'string', label: 'name', value: 'John' },
|
||||
};
|
||||
|
||||
const getCodes = (
|
||||
workflow: ValidatableWorkflow,
|
||||
): WorkflowValidationIssueCode[] => {
|
||||
const steps = workflow.steps ?? [];
|
||||
|
||||
return validateWorkflowVariableReferences({
|
||||
workflow,
|
||||
graph: buildWorkflowGraph(workflow),
|
||||
stepsById: new Map<string, ValidatableWorkflowStep>(
|
||||
steps.map((step) => [step.id, step]),
|
||||
),
|
||||
}).map((issue) => issue.code);
|
||||
};
|
||||
|
||||
describe('validateWorkflowVariableReferences', () => {
|
||||
it('should return no issues for a valid upstream reference that resolves', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1'] },
|
||||
steps: [
|
||||
{
|
||||
id: 's1',
|
||||
type: 'CODE',
|
||||
nextStepIds: ['s2'],
|
||||
settings: { outputSchema: OUTPUT_SCHEMA },
|
||||
},
|
||||
{
|
||||
id: 's2',
|
||||
type: 'CODE',
|
||||
settings: { input: { value: '{{s1.name}}' } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should flag a variable with an invalid path', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1'] },
|
||||
steps: [
|
||||
{ id: 's1', type: 'CODE', settings: { input: { value: '{{.}}' } } },
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toContain('VARIABLE_INVALID_PATH');
|
||||
});
|
||||
|
||||
it('should flag a reference to an unknown step', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1'] },
|
||||
steps: [
|
||||
{
|
||||
id: 's1',
|
||||
type: 'CODE',
|
||||
settings: { input: { value: '{{ghost.name}}' } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toContain('VARIABLE_UNKNOWN_STEP');
|
||||
});
|
||||
|
||||
it('should flag a reference to a step that does not run before it', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1', 's2'] },
|
||||
steps: [
|
||||
{
|
||||
id: 's1',
|
||||
type: 'CODE',
|
||||
settings: { input: { value: '{{s2.name}}' } },
|
||||
},
|
||||
{ id: 's2', type: 'CODE', settings: { outputSchema: OUTPUT_SCHEMA } },
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toContain('VARIABLE_NOT_UPSTREAM');
|
||||
});
|
||||
|
||||
it('should flag an upstream reference whose path is not found in the output schema', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1'] },
|
||||
steps: [
|
||||
{
|
||||
id: 's1',
|
||||
type: 'CODE',
|
||||
nextStepIds: ['s2'],
|
||||
settings: { outputSchema: OUTPUT_SCHEMA },
|
||||
},
|
||||
{
|
||||
id: 's2',
|
||||
type: 'CODE',
|
||||
settings: { input: { value: '{{s1.unknownField}}' } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toContain('VARIABLE_PATH_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('should resolve a valid trigger reference against the trigger output schema', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: {
|
||||
type: 'MANUAL',
|
||||
nextStepIds: ['s1'],
|
||||
settings: { outputSchema: OUTPUT_SCHEMA },
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
id: 's1',
|
||||
type: 'CODE',
|
||||
settings: { input: { value: '{{trigger.name}}' } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should flag an invalid path in the trigger output schema', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: {
|
||||
type: 'MANUAL',
|
||||
nextStepIds: ['s1'],
|
||||
settings: { outputSchema: OUTPUT_SCHEMA },
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
id: 's1',
|
||||
type: 'CODE',
|
||||
settings: { input: { value: '{{trigger.unknownField}}' } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toContain('VARIABLE_PATH_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('should not flag a self-reference as not-upstream', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL', nextStepIds: ['s1'] },
|
||||
steps: [
|
||||
{
|
||||
id: 's1',
|
||||
type: 'CODE',
|
||||
settings: {
|
||||
input: { value: '{{s1.name}}' },
|
||||
outputSchema: OUTPUT_SCHEMA,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).not.toContain('VARIABLE_NOT_UPSTREAM');
|
||||
});
|
||||
|
||||
// Regression: a trigger reference must never be flagged as not-upstream, even
|
||||
// when the trigger has no outgoing connections (so it is not in any ancestor set).
|
||||
it('should not flag a trigger reference as not-upstream when the trigger is disconnected', () => {
|
||||
const workflow: ValidatableWorkflow = {
|
||||
trigger: { type: 'MANUAL' },
|
||||
steps: [
|
||||
{
|
||||
id: 's1',
|
||||
type: 'CODE',
|
||||
settings: { input: { value: '{{trigger.foo}}' } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getCodes(workflow)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { isDefined } from '@/utils';
|
||||
import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId';
|
||||
import { type ValidatableWorkflow } from '@/workflow/validation/types/workflow-validation.type';
|
||||
import { getStepOutgoingStepIds } from '@/workflow/validation/utils/get-step-outgoing-step-ids.util';
|
||||
|
||||
export type WorkflowGraph = {
|
||||
childrenByStepId: Map<string, string[]>;
|
||||
reachableFromTrigger: Set<string>;
|
||||
ancestorsByStepId: Map<string, Set<string>>;
|
||||
};
|
||||
|
||||
export const buildWorkflowGraph = ({
|
||||
trigger,
|
||||
steps,
|
||||
}: ValidatableWorkflow): WorkflowGraph => {
|
||||
const childrenByStepId = new Map<string, string[]>();
|
||||
|
||||
const triggerNextStepIds = isDefined(trigger?.nextStepIds)
|
||||
? trigger.nextStepIds.filter(isDefined)
|
||||
: [];
|
||||
|
||||
childrenByStepId.set(TRIGGER_STEP_ID, triggerNextStepIds);
|
||||
|
||||
for (const step of steps ?? []) {
|
||||
childrenByStepId.set(step.id, getStepOutgoingStepIds(step));
|
||||
}
|
||||
|
||||
const reachableFromTrigger = new Set<string>();
|
||||
const queue: string[] = [TRIGGER_STEP_ID];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentStepId = queue.shift();
|
||||
|
||||
if (!isDefined(currentStepId) || reachableFromTrigger.has(currentStepId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
reachableFromTrigger.add(currentStepId);
|
||||
|
||||
for (const nextStepId of childrenByStepId.get(currentStepId) ?? []) {
|
||||
if (!reachableFromTrigger.has(nextStepId)) {
|
||||
queue.push(nextStepId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ancestorsByStepId = computeAncestors(childrenByStepId);
|
||||
|
||||
return { childrenByStepId, reachableFromTrigger, ancestorsByStepId };
|
||||
};
|
||||
|
||||
const computeAncestors = (
|
||||
childrenByStepId: Map<string, string[]>,
|
||||
): Map<string, Set<string>> => {
|
||||
const parentsByStepId = new Map<string, Set<string>>();
|
||||
|
||||
for (const [stepId, nextStepIds] of childrenByStepId.entries()) {
|
||||
for (const nextStepId of nextStepIds) {
|
||||
const parents = parentsByStepId.get(nextStepId) ?? new Set<string>();
|
||||
|
||||
parents.add(stepId);
|
||||
parentsByStepId.set(nextStepId, parents);
|
||||
}
|
||||
}
|
||||
|
||||
const ancestorsByStepId = new Map<string, Set<string>>();
|
||||
|
||||
for (const stepId of childrenByStepId.keys()) {
|
||||
if (ancestorsByStepId.has(stepId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ancestors = new Set<string>();
|
||||
const queue = [...(parentsByStepId.get(stepId) ?? [])];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const ancestorStepId = queue.shift()!;
|
||||
|
||||
if (ancestors.has(ancestorStepId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ancestors.add(ancestorStepId);
|
||||
|
||||
for (const grandParentStepId of parentsByStepId.get(ancestorStepId) ??
|
||||
[]) {
|
||||
if (!ancestors.has(grandParentStepId)) {
|
||||
queue.push(grandParentStepId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ancestorsByStepId.set(stepId, ancestors);
|
||||
}
|
||||
|
||||
return ancestorsByStepId;
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { isObject, isString } from '@sniptt/guards';
|
||||
|
||||
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '@/workflow/constants/CaptureAllVariableTagInnerRegex';
|
||||
|
||||
function* resolveVariables(value: unknown): Generator<string> {
|
||||
if (isString(value)) {
|
||||
for (const [, variablePath] of value.matchAll(
|
||||
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
|
||||
)) {
|
||||
yield variablePath;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isObject(value)) {
|
||||
for (const nestedValue of Object.values(value)) {
|
||||
yield* resolveVariables(nestedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const extractVariablesFromInput = (input: unknown): string[] => {
|
||||
return [...resolveVariables(input)];
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
// Levenshtein edit distance
|
||||
export const getEditDistance = (source: string, target: string): number => {
|
||||
const rowCount = source.length + 1;
|
||||
const columnCount = target.length + 1;
|
||||
|
||||
const matrix: number[][] = Array.from({ length: rowCount }, () =>
|
||||
new Array<number>(columnCount).fill(0),
|
||||
);
|
||||
|
||||
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
|
||||
matrix[rowIndex][0] = rowIndex;
|
||||
}
|
||||
|
||||
for (let columnIndex = 0; columnIndex < columnCount; columnIndex++) {
|
||||
matrix[0][columnIndex] = columnIndex;
|
||||
}
|
||||
|
||||
for (let rowIndex = 1; rowIndex < rowCount; rowIndex++) {
|
||||
for (let columnIndex = 1; columnIndex < columnCount; columnIndex++) {
|
||||
const substitutionCost =
|
||||
source[rowIndex - 1] === target[columnIndex - 1] ? 0 : 1;
|
||||
|
||||
matrix[rowIndex][columnIndex] = Math.min(
|
||||
matrix[rowIndex - 1][columnIndex] + 1,
|
||||
matrix[rowIndex][columnIndex - 1] + 1,
|
||||
matrix[rowIndex - 1][columnIndex - 1] + substitutionCost,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[source.length][target.length];
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { isDefined } from '@/utils';
|
||||
import { isIfElseStepInput } from '@/workflow/validation/guards/isIfElseStepInput';
|
||||
import { isIteratorStepInput } from '@/workflow/validation/guards/isIteratorStepInput';
|
||||
import { type ValidatableWorkflowStep } from '@/workflow/validation/types/workflow-validation.type';
|
||||
import { isObject } from '@sniptt/guards';
|
||||
|
||||
export const getStepInput = (
|
||||
step: ValidatableWorkflowStep,
|
||||
): Record<string, unknown> | undefined => {
|
||||
const input = step.settings?.input;
|
||||
|
||||
if (isDefined(input) && isObject(input)) {
|
||||
return input as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getStepOutgoingStepIds = (
|
||||
step: ValidatableWorkflowStep,
|
||||
): string[] => {
|
||||
const outgoingStepIds = new Set<string>(step.nextStepIds ?? []);
|
||||
|
||||
if (isIfElseStepInput(step)) {
|
||||
for (const branch of step.settings.input.branches ?? []) {
|
||||
for (const nextStepId of branch?.nextStepIds ?? []) {
|
||||
outgoingStepIds.add(nextStepId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isIteratorStepInput(step)) {
|
||||
for (const nextStepId of step.settings.input.initialLoopStepIds ?? []) {
|
||||
outgoingStepIds.add(nextStepId);
|
||||
}
|
||||
}
|
||||
|
||||
return [...outgoingStepIds];
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { isNonEmptyArray } from '@sniptt/guards';
|
||||
|
||||
import { isDefined, isPlainObject } from '@/utils';
|
||||
import { getEditDistance } from '@/workflow/validation/utils/get-edit-distance.util';
|
||||
import { isBaseOutputSchemaV2 } from '@/workflow/workflow-schema/guards/isBaseOutputSchemaV2';
|
||||
import { collectOutputSchemaPaths } from '@/workflow/workflow-schema/utils/collect-output-schema-paths';
|
||||
import { findOutputSchemaPathFailure } from '@/workflow/workflow-schema/utils/find-output-schema-path-failure';
|
||||
import { collectOutputSchemaVariablePaths } from '@/workflow/workflow-schema/utils/resolve-variable-path-in-output-schema';
|
||||
|
||||
const MAX_SUGGESTIONS = 3;
|
||||
|
||||
const containsRecordOutputSchema = (value: unknown): boolean => {
|
||||
if (!isPlainObject(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value['_outputSchemaType'] === 'RECORD') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Object.values(value).some(
|
||||
(entry) =>
|
||||
isPlainObject(entry) && containsRecordOutputSchema(entry['value']),
|
||||
);
|
||||
};
|
||||
|
||||
const rankClosestCandidates = (
|
||||
target: string,
|
||||
candidates: string[],
|
||||
): string[] =>
|
||||
candidates
|
||||
.map((candidate) => ({
|
||||
candidate,
|
||||
distance: getEditDistance(target, candidate),
|
||||
}))
|
||||
.filter(
|
||||
({ candidate, distance }) => distance <= Math.ceil(candidate.length / 2),
|
||||
)
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
.slice(0, MAX_SUGGESTIONS)
|
||||
.map(({ candidate }) => candidate);
|
||||
|
||||
export const getVariablePathSuggestions = ({
|
||||
schema,
|
||||
propertyPath,
|
||||
referencedStepId,
|
||||
}: {
|
||||
schema: unknown;
|
||||
propertyPath: string[];
|
||||
referencedStepId: string;
|
||||
}): string[] => {
|
||||
if (!isBaseOutputSchemaV2(schema) || containsRecordOutputSchema(schema)) {
|
||||
const allPaths = collectOutputSchemaVariablePaths(schema);
|
||||
|
||||
return rankClosestCandidates(propertyPath.join('.'), allPaths).map((path) =>
|
||||
[referencedStepId, path].join('.'),
|
||||
);
|
||||
}
|
||||
|
||||
const failure = findOutputSchemaPathFailure({ schema, propertyPath });
|
||||
|
||||
if (!isDefined(failure)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const localMatches = rankClosestCandidates(
|
||||
failure.failedSegment,
|
||||
failure.availableKeys,
|
||||
).map((key) => [referencedStepId, ...failure.validPrefix, key].join('.'));
|
||||
|
||||
if (isNonEmptyArray(localMatches)) {
|
||||
return localMatches;
|
||||
}
|
||||
|
||||
const allPaths = collectOutputSchemaPaths(schema);
|
||||
|
||||
return rankClosestCandidates(propertyPath.join('.'), allPaths).map((path) =>
|
||||
[referencedStepId, path].join('.'),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
import { isDefined } from '@/utils';
|
||||
import { WorkflowActionType } from '@/workflow/types/WorkflowActionType';
|
||||
import {
|
||||
type IfElseStepInput,
|
||||
type IteratorStepInput,
|
||||
type ValidatableWorkflow,
|
||||
type ValidatableWorkflowStep,
|
||||
type WorkflowValidationIssue,
|
||||
} from '@/workflow/validation/types/workflow-validation.type';
|
||||
import { type WorkflowGraph } from '@/workflow/validation/utils/build-workflow-graph.util';
|
||||
import { getStepInput } from '@/workflow/validation/utils/get-step-outgoing-step-ids.util';
|
||||
|
||||
export const validateWorkflowGraph = ({
|
||||
workflow,
|
||||
graph,
|
||||
}: {
|
||||
workflow: ValidatableWorkflow;
|
||||
graph: WorkflowGraph;
|
||||
}): WorkflowValidationIssue[] => {
|
||||
const issues: WorkflowValidationIssue[] = [];
|
||||
const steps = workflow.steps ?? [];
|
||||
const stepIds = new Set(steps.map((step) => step.id));
|
||||
|
||||
const seenStepIds = new Set<string>();
|
||||
|
||||
for (const step of steps) {
|
||||
if (seenStepIds.has(step.id)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'DUPLICATE_STEP_ID',
|
||||
message: `Duplicate step id "${step.id}". Every step id must be unique within the workflow.`,
|
||||
stepId: step.id,
|
||||
});
|
||||
}
|
||||
seenStepIds.add(step.id);
|
||||
}
|
||||
|
||||
const triggerNextStepIds = (workflow.trigger?.nextStepIds ?? []).filter(
|
||||
isDefined,
|
||||
);
|
||||
|
||||
if (steps.length > 0 && triggerNextStepIds.length === 0) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'TRIGGER_HAS_NO_NEXT_STEP',
|
||||
message: `The trigger is not connected to any step. The trigger must have a "nextStepIds" array pointing to the first step (e.g. nextStepIds: ["${steps[0].id}"]). If you used edges, also set trigger.nextStepIds.`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const triggerNextStepId of triggerNextStepIds) {
|
||||
if (!stepIds.has(triggerNextStepId)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'DANGLING_REFERENCE',
|
||||
message: `The trigger references a non-existent step "${triggerNextStepId}".`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const step of steps) {
|
||||
for (const outgoingStepId of graph.childrenByStepId.get(step.id) ?? []) {
|
||||
if (!stepIds.has(outgoingStepId)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'DANGLING_REFERENCE',
|
||||
message: `Step "${step.name ?? step.id}" references a non-existent step "${outgoingStepId}".`,
|
||||
stepId: step.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
issues.push(...validateBranchingStep(step));
|
||||
}
|
||||
|
||||
for (const step of steps) {
|
||||
if (!graph.reachableFromTrigger.has(step.id)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'UNREACHABLE_STEP',
|
||||
message: `Step "${step.name ?? step.id}" is not reachable from the trigger. Ensure a chain of nextStepIds connects the trigger to this step. Check that the preceding step includes this step's id ("${step.id}") in its nextStepIds array.`,
|
||||
stepId: step.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
};
|
||||
|
||||
const validateBranchingStep = (
|
||||
step: ValidatableWorkflowStep,
|
||||
): WorkflowValidationIssue[] => {
|
||||
const issues: WorkflowValidationIssue[] = [];
|
||||
const input = getStepInput(step);
|
||||
|
||||
if (step.type === WorkflowActionType.IF_ELSE) {
|
||||
const branchList = (input as Partial<IfElseStepInput> | undefined)
|
||||
?.branches;
|
||||
const branches = Array.isArray(branchList) ? branchList : [];
|
||||
|
||||
if (branches.length < 2) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'IF_ELSE_INSUFFICIENT_BRANCHES',
|
||||
message: `If/Else step "${step.name ?? step.id}" must have at least two branches (a condition branch and an else branch).`,
|
||||
stepId: step.id,
|
||||
});
|
||||
}
|
||||
|
||||
for (const branch of branches) {
|
||||
const branchNextStepIds = branch?.nextStepIds;
|
||||
|
||||
if (!Array.isArray(branchNextStepIds) || branchNextStepIds.length === 0) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'IF_ELSE_BRANCH_HAS_NO_NEXT_STEP',
|
||||
message: `A branch of If/Else step "${step.name ?? step.id}" is not connected to any step.`,
|
||||
stepId: step.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (step.type === WorkflowActionType.ITERATOR) {
|
||||
const iteratorInput = input as Partial<IteratorStepInput> | undefined;
|
||||
const items = iteratorInput?.items;
|
||||
const hasConfiguredItems =
|
||||
(typeof items === 'string' && items.length > 0) ||
|
||||
(Array.isArray(items) && items.length > 0);
|
||||
const initialLoopStepIds = iteratorInput?.initialLoopStepIds;
|
||||
const hasLoopBody =
|
||||
Array.isArray(initialLoopStepIds) && initialLoopStepIds.length > 0;
|
||||
|
||||
if (hasConfiguredItems && !hasLoopBody) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'ITERATOR_MISSING_LOOP_BODY',
|
||||
message: `Iterator step "${step.name ?? step.id}" has items to iterate over but no steps inside the loop.`,
|
||||
stepId: step.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { workflowActionSchema } from '@/workflow/schemas/workflow-action-schema';
|
||||
import { workflowTriggerSchema } from '@/workflow/schemas/workflow-trigger-schema';
|
||||
import { isDefined } from '@/utils';
|
||||
import {
|
||||
type ValidatableWorkflow,
|
||||
type WorkflowValidationIssue,
|
||||
} from '@/workflow/validation/types/workflow-validation.type';
|
||||
import { type z } from 'zod';
|
||||
|
||||
const formatZodPath = (path: PropertyKey[]): string =>
|
||||
path.map((segment) => String(segment)).join('.');
|
||||
|
||||
const formatZodIssue = (issue: z.ZodIssue): string => {
|
||||
const path = formatZodPath(issue.path);
|
||||
const base = path.length > 0 ? `${path}: ${issue.message}` : issue.message;
|
||||
|
||||
if (issue.code === 'invalid_type' && path.length > 0) {
|
||||
return `${base}. Ensure the field "${path}" exists at the correct nesting level in the step object (not inside "input").`;
|
||||
}
|
||||
|
||||
return base;
|
||||
};
|
||||
|
||||
const formatZodIssues = (zodError: z.ZodError): string[] =>
|
||||
zodError.issues.map(formatZodIssue);
|
||||
|
||||
export const validateWorkflowStepParams = ({
|
||||
trigger,
|
||||
steps,
|
||||
}: ValidatableWorkflow): WorkflowValidationIssue[] => {
|
||||
const issues: WorkflowValidationIssue[] = [];
|
||||
|
||||
if (isDefined(trigger)) {
|
||||
const triggerResult = workflowTriggerSchema.safeParse(trigger);
|
||||
|
||||
if (!triggerResult.success) {
|
||||
for (const message of formatZodIssues(triggerResult.error)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'INVALID_TRIGGER_PARAMS',
|
||||
message: `Trigger configuration is invalid - ${message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const step of steps ?? []) {
|
||||
const stepResult = workflowActionSchema.safeParse(step);
|
||||
|
||||
if (!stepResult.success) {
|
||||
for (const message of formatZodIssues(stepResult.error)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'INVALID_STEP_PARAMS',
|
||||
message: `Step "${step.name ?? step.id}" configuration is invalid - ${message}`,
|
||||
stepId: step.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
import { isNonEmptyArray, isObject } from '@sniptt/guards';
|
||||
|
||||
import { isDefined } from '@/utils';
|
||||
import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId';
|
||||
import { parseVariablePath } from '@/workflow/utils/variable-path.util';
|
||||
import {
|
||||
type ValidatableWorkflow,
|
||||
type ValidatableWorkflowStep,
|
||||
type WorkflowValidationIssue,
|
||||
} from '@/workflow/validation/types/workflow-validation.type';
|
||||
import { type WorkflowGraph } from '@/workflow/validation/utils/build-workflow-graph.util';
|
||||
import { extractVariablesFromInput } from '@/workflow/validation/utils/extract-variables-from-input.util';
|
||||
import { getVariablePathSuggestions } from '@/workflow/validation/utils/get-variable-path-suggestions.util';
|
||||
import {
|
||||
collectOutputSchemaVariablePaths,
|
||||
resolveVariablePathInOutputSchema,
|
||||
} from '@/workflow/workflow-schema/utils/resolve-variable-path-in-output-schema';
|
||||
|
||||
export const validateWorkflowVariableReferences = ({
|
||||
workflow,
|
||||
graph,
|
||||
stepsById,
|
||||
}: {
|
||||
workflow: ValidatableWorkflow;
|
||||
graph: WorkflowGraph;
|
||||
stepsById: Map<string, ValidatableWorkflowStep>;
|
||||
}): WorkflowValidationIssue[] => {
|
||||
const issues: WorkflowValidationIssue[] = [];
|
||||
const stepIds = new Set(workflow.steps?.map((step) => step.id) ?? []);
|
||||
|
||||
for (const step of workflow.steps ?? []) {
|
||||
const variables = extractVariablesFromInput(step.settings?.input);
|
||||
const ancestors = graph.ancestorsByStepId.get(step.id) ?? new Set<string>();
|
||||
|
||||
for (const variable of variables) {
|
||||
const pathSegments = parseVariablePath(variable);
|
||||
const referencedStepId = pathSegments[0];
|
||||
|
||||
if (!isDefined(referencedStepId)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'VARIABLE_INVALID_PATH',
|
||||
message: `Step "${step.name ?? step.id}" has a variable "{{${variable}}}" with an invalid path. Variable references must start with a step ID, e.g. "{{stepId.property}}".`,
|
||||
stepId: step.id,
|
||||
path: variable,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const isTriggerReference = referencedStepId === TRIGGER_STEP_ID;
|
||||
|
||||
if (!isTriggerReference && !stepIds.has(referencedStepId)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'VARIABLE_UNKNOWN_STEP',
|
||||
message: `Step "${step.name ?? step.id}" references variable "{{${variable}}}" from an unknown step "${referencedStepId}".`,
|
||||
stepId: step.id,
|
||||
path: variable,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const isSelfReference = referencedStepId === step.id;
|
||||
|
||||
// The trigger always runs before every step, so a trigger reference is
|
||||
// upstream by definition even when it is not present in the ancestor set.
|
||||
if (
|
||||
!isTriggerReference &&
|
||||
!isSelfReference &&
|
||||
!ancestors.has(referencedStepId)
|
||||
) {
|
||||
const referencedStep = stepsById.get(referencedStepId);
|
||||
const referencedStepLabel = referencedStep?.name ?? referencedStepId;
|
||||
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'VARIABLE_NOT_UPSTREAM',
|
||||
message: `Step "${step.name ?? step.id}" references variable "{{${variable}}}" from step "${referencedStepLabel}", which does not run before it. Ensure step "${referencedStepLabel}" is an ancestor (connected via nextStepIds chain from the trigger, before this step).`,
|
||||
stepId: step.id,
|
||||
path: variable,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
issues.push(
|
||||
...validateVariablePathAgainstOutputSchema({
|
||||
step,
|
||||
variable,
|
||||
pathSegments,
|
||||
referencedStepId,
|
||||
isTriggerReference,
|
||||
trigger: workflow.trigger,
|
||||
stepsById,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
};
|
||||
|
||||
const validateVariablePathAgainstOutputSchema = ({
|
||||
step,
|
||||
variable,
|
||||
pathSegments,
|
||||
referencedStepId,
|
||||
isTriggerReference,
|
||||
trigger,
|
||||
stepsById,
|
||||
}: {
|
||||
step: ValidatableWorkflowStep;
|
||||
variable: string;
|
||||
pathSegments: string[];
|
||||
referencedStepId: string;
|
||||
isTriggerReference: boolean;
|
||||
trigger: ValidatableWorkflow['trigger'];
|
||||
stepsById: Map<string, ValidatableWorkflowStep>;
|
||||
}): WorkflowValidationIssue[] => {
|
||||
const propertyPath = pathSegments.slice(1);
|
||||
|
||||
if (propertyPath.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const outputSchema = isTriggerReference
|
||||
? trigger?.settings?.outputSchema
|
||||
: stepsById.get(referencedStepId)?.settings?.outputSchema;
|
||||
|
||||
const isEmptyOutputSchema =
|
||||
isDefined(outputSchema) &&
|
||||
isObject(outputSchema) &&
|
||||
!Array.isArray(outputSchema) &&
|
||||
Object.keys(outputSchema).length === 0;
|
||||
|
||||
if (!isDefined(outputSchema) || isEmptyOutputSchema) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resolved = resolveVariablePathInOutputSchema({
|
||||
schema: outputSchema,
|
||||
propertyPath,
|
||||
});
|
||||
|
||||
if (!resolved.found) {
|
||||
const suggestions = getVariablePathSuggestions({
|
||||
schema: outputSchema,
|
||||
propertyPath,
|
||||
referencedStepId,
|
||||
});
|
||||
|
||||
const availablePaths = collectAvailablePaths(
|
||||
outputSchema,
|
||||
referencedStepId,
|
||||
);
|
||||
|
||||
const hint = isNonEmptyArray(suggestions)
|
||||
? `Did you mean "{{${suggestions[0]}}}"?${
|
||||
suggestions.length > 1
|
||||
? ` Other options: ${suggestions
|
||||
.slice(1)
|
||||
.map((suggestion) => `{{${suggestion}}}`)
|
||||
.join(', ')}.`
|
||||
: ''
|
||||
}`
|
||||
: isNonEmptyArray(availablePaths)
|
||||
? `Available paths: ${availablePaths.map((path) => `{{${path}}}`).join(', ')}.`
|
||||
: undefined;
|
||||
|
||||
return [
|
||||
{
|
||||
severity: 'error',
|
||||
code: 'VARIABLE_PATH_NOT_FOUND',
|
||||
message: `Step "${step.name ?? step.id}" references variable "{{${variable}}}" but the path "${propertyPath.join('.')}" was not found in the output of step "${referencedStepId}".`,
|
||||
stepId: step.id,
|
||||
path: variable,
|
||||
...(isDefined(hint) ? { hint } : {}),
|
||||
...(isNonEmptyArray(suggestions) ? { suggestions } : {}),
|
||||
...(isNonEmptyArray(availablePaths) ? { availablePaths } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const MAX_AVAILABLE_PATHS = 20;
|
||||
|
||||
const collectAvailablePaths = (
|
||||
outputSchema: unknown,
|
||||
referencedStepId: string,
|
||||
): string[] =>
|
||||
collectOutputSchemaVariablePaths(outputSchema)
|
||||
.slice(0, MAX_AVAILABLE_PATHS)
|
||||
.map((path) => `${referencedStepId}.${path}`);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user