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,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}`);
@@ -0,0 +1,74 @@
import { isDefined } from '@/utils';
import {
type ValidatableWorkflow,
type ValidatableWorkflowStep,
type WorkflowValidationIssue,
type WorkflowValidationResult,
} from '@/workflow/validation/types/workflow-validation.type';
import { buildWorkflowGraph } from '@/workflow/validation/utils/build-workflow-graph.util';
import { validateWorkflowGraph } from '@/workflow/validation/utils/validate-workflow-graph.util';
import { validateWorkflowStepParams } from '@/workflow/validation/utils/validate-workflow-step-params.util';
import { validateWorkflowVariableReferences } from '@/workflow/validation/utils/validate-workflow-variable-references.util';
const buildResult = (
issues: WorkflowValidationIssue[],
): WorkflowValidationResult => {
const errors = issues.filter((issue) => issue.severity === 'error');
const warnings = issues.filter((issue) => issue.severity === 'warning');
return {
valid: errors.length === 0,
errors,
warnings,
};
};
export const validateWorkflowStructure = (
workflow: ValidatableWorkflow,
): WorkflowValidationResult => {
const issues: WorkflowValidationIssue[] = [];
if (!isDefined(workflow.trigger)) {
issues.push({
severity: 'error',
code: 'MISSING_TRIGGER',
message: 'The workflow has no trigger.',
});
} else if (!isDefined(workflow.trigger.type)) {
issues.push({
severity: 'error',
code: 'MISSING_TRIGGER_TYPE',
message: 'The workflow trigger has no type.',
});
}
const steps = workflow.steps ?? [];
if (steps.length === 0) {
issues.push({
severity: 'error',
code: 'NO_STEPS',
message: 'The workflow has no steps.',
});
return buildResult(issues);
}
const stepsById = new Map<string, ValidatableWorkflowStep>(
steps.map((step) => [step.id, step]),
);
const graph = buildWorkflowGraph(workflow);
issues.push(...validateWorkflowGraph({ workflow, graph }));
issues.push(...validateWorkflowStepParams(workflow));
issues.push(
...validateWorkflowVariableReferences({
workflow,
graph,
stepsById,
}),
);
return buildResult(issues);
};