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:
Etienne
2026-06-12 10:23:03 +02:00
committed by GitHub
parent 2538239e05
commit fefd9d7704
120 changed files with 4445 additions and 1192 deletions
@@ -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' }]);
});
});