feat(workflow): auto-layout steps on AI workflow creation via shared tidy-up (#21756)

## Context

The workflow builder has a "Tidy up" action that auto-positions steps
using a
Dagre layout. However, this lived entirely in the frontend and depended
on node
dimensions measured by React Flow after rendering in the browser.

As a result, workflows (and steps) created through AI Chat / MCP tools
were never
laid out: `create_complete_workflow` accepted optional `stepPositions`
that the
LLM had to invent, and `create_workflow_version_step` stored an optional
position
verbatim. In practice this produced overlapping / poorly positioned
steps.

## What this does

Extracts the tidy-up layout into a pure, frontend-free util in
`twenty-shared` and
reuses it from both the frontend tidy-up and the server, so
AI/MCP-created
workflows are auto-laid out at creation time.

### twenty-shared
- New `computeWorkflowLayout({ nodes, edges, options? })` — a pure Dagre
layout over
a minimal `{ id, width, height }` / `{ source, target }` graph,
returning
top-left-anchored positions (matching React Flow). Ignores edges
pointing to
  unknown nodes.
- New constants: `WORKFLOW_LAYOUT_DEFAULT_OPTIONS`
(ranksep/nodesep/rankdir) and
`WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS` (estimated node size for
server-side
  layout, where measured sizes are unavailable).
- Added `@dagrejs/dagre` dependency.

### twenty-front
- `getOrganizedDiagram` now delegates to `computeWorkflowLayout`,
passing real
  measured node sizes. No behavior change for users.

### twenty-server
- New `WorkflowVersionWorkspaceService.autoLayoutWorkflowVersion(...)`
builds the
graph topology via the existing `buildWorkflowGraph` (covers if-else
branches and
iterator loops), feeds estimated node sizes into
`computeWorkflowLayout`, and
  persists through the existing `updateWorkflowVersionPositions`.
- `create_complete_workflow`: removed `stepPositions` from the tool
schema; the
  server always auto-lays out after creation/edges.
- `create_workflow_version_step`: re-tidies the whole version after each
added step
(wired at the tool level so the builder UI is unaffected) and dropped
the now
  redundant `position` field.

## Notes
- Server-side layout uses estimated node sizes, so it is "good enough";
opening the
workflow and running the existing FE tidy-up refines it with real
measured sizes.
- Auto-layout is wired in the MCP tools, not in the shared creation
service, so
  manual step creation in the builder UI is unchanged.

## Test plan
- [x] `twenty-shared` unit tests for `computeWorkflowLayout` (linear
chain, if-else
  spread, dangling-edge safety)
- [x] `twenty-shared` builds; `twenty-server` and `twenty-front`
typecheck
- [x] Lint/format clean on changed files
- [ ] Create a workflow via AI Chat / MCP and confirm steps are laid out
without
  overlap
- [x] Add a step via MCP and confirm the version is re-tidied
- [ ] Frontend "Tidy up" still behaves as before

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21756?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Etienne
2026-06-18 16:10:04 +02:00
committed by GitHub
parent f1d4d6aeaf
commit c6309fd92b
12 changed files with 275 additions and 62 deletions
@@ -1,36 +1,30 @@
import { type WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import Dagre from '@dagrejs/dagre';
import { computeWorkflowLayout } from 'twenty-shared/workflow';
export const getOrganizedDiagram = (
diagram: WorkflowDiagram,
): WorkflowDiagram => {
const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
graph.setGraph({
ranksep: 80, // Vertical distance between 2 nodes
nodesep: 200, // Horizontal distance between 2 nodes
rankdir: 'TB',
});
diagram.edges.forEach((edge) => graph.setEdge(edge.source, edge.target));
diagram.nodes.forEach((node) =>
graph.setNode(node.id, {
const positions = computeWorkflowLayout({
nodes: diagram.nodes.map((node) => ({
id: node.id,
width: node.measured?.width ?? 0,
height: node.measured?.height ?? 0,
}),
);
})),
edges: diagram.edges.map((edge) => ({
source: edge.source,
target: edge.target,
})),
});
Dagre.layout(graph);
const positionByNodeId = new Map(
positions.map((position) => [position.id, position.position]),
);
return {
nodes: diagram.nodes.map((node) => {
const position = graph.node(node.id);
const position = positionByNodeId.get(node.id);
// We are shifting the dagre node position (anchor=center center) to the top left
// so it matches the React Flow node anchor point (top left).
const x = position.x - position.width / 2;
const y = position.y - position.height / 2;
return { ...node, position: { x, y } };
return position ? { ...node, position } : node;
}),
edges: diagram.edges,
};
@@ -6,6 +6,7 @@ import { CacheLockModule } from 'src/engine/core-modules/cache-lock/cache-lock.m
import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module';
import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
import { WorkflowSchemaModule } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.module';
import { WorkflowVersionStepModule } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.module';
import { WorkflowVersionWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service';
@@ -15,6 +16,7 @@ import { WorkflowVersionWorkspaceService } from 'src/modules/workflow/workflow-b
WorkflowSchemaModule,
LogicFunctionModule,
WorkflowVersionStepModule,
WorkflowCommonModule,
NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity]),
RecordPositionModule,
CacheLockModule,
@@ -1,7 +1,13 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID, WorkflowActionType } from 'twenty-shared/workflow';
import {
buildWorkflowGraph,
computeWorkflowLayout,
TRIGGER_STEP_ID,
WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS,
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';
@@ -23,6 +29,7 @@ import {
import { assertWorkflowVersionHasSteps } from 'src/modules/workflow/common/utils/assert-workflow-version-has-steps';
import { assertWorkflowVersionIsDraft } from 'src/modules/workflow/common/utils/assert-workflow-version-is-draft.util';
import { assertWorkflowVersionTriggerIsDefined } from 'src/modules/workflow/common/utils/assert-workflow-version-trigger-is-defined.util';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
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 { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
@@ -34,6 +41,7 @@ export class WorkflowVersionWorkspaceService {
private readonly workflowVersionStepWorkspaceService: WorkflowVersionStepWorkspaceService,
private readonly workflowVersionStepOperationsWorkspaceService: WorkflowVersionStepOperationsWorkspaceService,
private readonly recordPositionService: RecordPositionService,
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
) {}
@WithLock('workflowId')
@@ -382,4 +390,50 @@ export class WorkflowVersionWorkspaceService {
await workflowVersionRepository.update(workflowVersionId, updatePayload);
}, authContext);
}
async autoLayoutWorkflowVersion({
workflowVersionId,
workspaceId,
}: {
workflowVersionId: string;
workspaceId: string;
}) {
const workflowVersion =
await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({
workspaceId,
workflowVersionId,
});
assertWorkflowVersionIsDraft(workflowVersion);
const steps = workflowVersion.steps ?? [];
const { childrenByStepId } = buildWorkflowGraph({
trigger: workflowVersion.trigger,
steps,
});
const nodes = [
{
id: TRIGGER_STEP_ID,
...WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS,
},
...steps.map((step) => ({
id: step.id,
...WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS,
})),
];
const edges = [...childrenByStepId.entries()].flatMap(([source, targets]) =>
targets.map((target) => ({ source, target })),
);
const positions = computeWorkflowLayout({ nodes, edges });
await this.updateWorkflowVersionPositions({
workflowVersionId,
positions,
workspaceId,
});
}
}
@@ -32,20 +32,6 @@ const createCompleteWorkflowSchema = z.object({
steps: z
.array(workflowActionSchema)
.describe('Array of workflow action steps'),
stepPositions: z
.array(
z.object({
stepId: z
.string()
.describe('The ID of the step (use "trigger" for trigger step)'),
position: z.object({
x: z.number().describe('X coordinate for the step position'),
y: z.number().describe('Y coordinate for the step position'),
}),
}),
)
.optional()
.describe('Optional array of step positions for layout'),
edges: z
.array(
z.object({
@@ -92,7 +78,8 @@ CRITICAL SCHEMA REQUIREMENTS:
- Each step MUST include: id (must be a valid UUID), name, type, valid, settings
- CREATE_RECORD actions MUST have objectName and objectRecord in settings.input
- objectRecord must contain actual field values, not just field names
- Use "trigger" as stepId for trigger step in stepPositions and edges
- Use "trigger" as the id for the trigger step in edges
- Step positions are computed automatically; do not provide coordinates
Common mistakes to avoid:
- Using "RECORD_CREATED" instead of "DATABASE_EVENT"
@@ -118,10 +105,6 @@ The response includes a compact validation summary. For the full validation repo
description?: string;
trigger: WorkflowTrigger;
steps: WorkflowAction[];
stepPositions?: Array<{
stepId: string;
position: { x: number; y: number };
}>;
edges?: Array<{ source: string; target: string }>;
activate?: boolean;
}) => {
@@ -161,19 +144,6 @@ The response includes a compact validation summary. For the full validation repo
steps: parameters.steps,
});
if (parameters.stepPositions && parameters.stepPositions.length > 0) {
const positions = parameters.stepPositions.map((pos) => ({
id: pos.stepId === 'trigger' ? 'trigger' : pos.stepId,
position: pos.position,
}));
await deps.workflowVersionService.updateWorkflowVersionPositions({
workflowVersionId,
positions,
workspaceId: context.workspaceId,
});
}
if (parameters.edges && parameters.edges.length > 0) {
for (const edge of parameters.edges) {
await deps.workflowVersionEdgeService.createWorkflowVersionEdge({
@@ -185,6 +155,11 @@ The response includes a compact validation summary. For the full validation repo
}
}
await deps.workflowVersionService.autoLayoutWorkflowVersion({
workflowVersionId,
workspaceId: context.workspaceId,
});
if (parameters.activate) {
await deps.workflowTriggerService.activateWorkflowVersion(
workflowVersionId,
@@ -31,13 +31,6 @@ const baseStepFields = {
.string()
.optional()
.describe('Optional ID of the step this new step should connect to'),
position: z
.object({
x: z.number(),
y: z.number(),
})
.optional()
.describe('Optional position coordinates for the step'),
};
const nonLogicFunctionStepTypes = Object.values(WorkflowActionType).filter(
@@ -100,7 +93,9 @@ const enrichResultWithNextStep = ({
export const createCreateWorkflowVersionStepTool = (
deps: Pick<
WorkflowToolDependencies,
'workflowVersionStepService' | 'workflowVersionStepHelpersService'
| 'workflowVersionStepService'
| 'workflowVersionStepHelpersService'
| 'workflowVersionService'
>,
context: WorkflowToolContext,
) => ({
@@ -148,6 +143,11 @@ export const createCreateWorkflowVersionStepTool = (
},
});
await deps.workflowVersionService.autoLayoutWorkflowVersion({
workflowVersionId: parameters.workflowVersionId,
workspaceId: context.workspaceId,
});
return enrichResultWithNextStep({
result,
stepType: parameters.stepType,
+1
View File
@@ -43,6 +43,7 @@
"vite-tsconfig-paths": "^4.2.1"
},
"dependencies": {
"@dagrejs/dagre": "^1.1.8",
"@sniptt/guards": "^0.2.0",
"ai": "6.0.97",
"class-validator": "^0.14.0",
@@ -18,6 +18,15 @@ export { WORKFLOW_TRIGGER_METADATA_WORKSPACE_MEMBER_ID_KEY } from './constants/W
export { WORKFLOW_TRIGGER_METADATA_WORKSPACE_MEMBER_ID_LABEL } from './constants/WorkflowTriggerMetadataWorkspaceMemberIdLabel';
export { WORKFLOW_TRIGGER_PAYLOAD_KEY } from './constants/WorkflowTriggerPayloadKey';
export { WORKFLOW_TRIGGER_PAYLOAD_LABEL } from './constants/WorkflowTriggerPayloadLabel';
export { WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS } from './layout/constants/WorkflowDiagramDefaultNodeDimensions';
export { WORKFLOW_LAYOUT_DEFAULT_OPTIONS } from './layout/constants/WorkflowLayoutDefaultOptions';
export type {
WorkflowLayoutNode,
WorkflowLayoutEdge,
WorkflowLayoutPosition,
WorkflowLayoutOptions,
} from './layout/utils/compute-workflow-layout.util';
export { computeWorkflowLayout } from './layout/utils/compute-workflow-layout.util';
export { workflowAiAgentActionSchema } from './schemas/ai-agent-action-schema';
export { workflowAiAgentActionSettingsSchema } from './schemas/ai-agent-action-settings-schema';
export { baseTriggerSchema } from './schemas/base-trigger-schema';
@@ -0,0 +1,4 @@
export const WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS = {
width: 240,
height: 52,
} as const;
@@ -0,0 +1,5 @@
export const WORKFLOW_LAYOUT_DEFAULT_OPTIONS = {
ranksep: 80, // Vertical distance between 2 nodes
nodesep: 200, // Horizontal distance between 2 nodes
rankdir: 'TB',
} as const;
@@ -0,0 +1,99 @@
import { WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS } from '@/workflow/layout/constants/WorkflowDiagramDefaultNodeDimensions';
import {
computeWorkflowLayout,
type WorkflowLayoutEdge,
type WorkflowLayoutNode,
} from '@/workflow/layout/utils/compute-workflow-layout.util';
const buildNode = (id: string): WorkflowLayoutNode => ({
id,
...WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS,
});
const doNodesOverlap = (
a: { x: number; y: number },
b: { x: number; y: number },
): boolean => {
const { width, height } = WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS;
return Math.abs(a.x - b.x) < width && Math.abs(a.y - b.y) < height;
};
describe('computeWorkflowLayout', () => {
it('should return a position for every node', () => {
const nodes = [buildNode('trigger'), buildNode('a'), buildNode('b')];
const edges: WorkflowLayoutEdge[] = [
{ source: 'trigger', target: 'a' },
{ source: 'a', target: 'b' },
];
const positions = computeWorkflowLayout({ nodes, edges });
expect(positions).toHaveLength(3);
expect(positions.map((position) => position.id).sort()).toEqual([
'a',
'b',
'trigger',
]);
});
it('should stack a linear chain vertically without overlap (rankdir TB)', () => {
const nodes = [buildNode('trigger'), buildNode('a'), buildNode('b')];
const edges: WorkflowLayoutEdge[] = [
{ source: 'trigger', target: 'a' },
{ source: 'a', target: 'b' },
];
const positionById = new Map(
computeWorkflowLayout({ nodes, edges }).map((position) => [
position.id,
position.position,
]),
);
const trigger = positionById.get('trigger')!;
const stepA = positionById.get('a')!;
const stepB = positionById.get('b')!;
expect(stepA.y).toBeGreaterThan(trigger.y);
expect(stepB.y).toBeGreaterThan(stepA.y);
});
it('should spread if-else branches horizontally without overlap', () => {
const nodes = [
buildNode('trigger'),
buildNode('ifElse'),
buildNode('branchA'),
buildNode('branchB'),
];
const edges: WorkflowLayoutEdge[] = [
{ source: 'trigger', target: 'ifElse' },
{ source: 'ifElse', target: 'branchA' },
{ source: 'ifElse', target: 'branchB' },
];
const positionById = new Map(
computeWorkflowLayout({ nodes, edges }).map((position) => [
position.id,
position.position,
]),
);
const branchA = positionById.get('branchA')!;
const branchB = positionById.get('branchB')!;
expect(branchA.x).not.toEqual(branchB.x);
expect(doNodesOverlap(branchA, branchB)).toBe(false);
});
it('should ignore edges pointing to unknown nodes', () => {
const nodes = [buildNode('trigger'), buildNode('a')];
const edges: WorkflowLayoutEdge[] = [
{ source: 'trigger', target: 'a' },
{ source: 'a', target: 'does-not-exist' },
];
expect(() => computeWorkflowLayout({ nodes, edges })).not.toThrow();
expect(computeWorkflowLayout({ nodes, edges })).toHaveLength(2);
});
});
@@ -0,0 +1,69 @@
import Dagre from '@dagrejs/dagre';
import { WORKFLOW_LAYOUT_DEFAULT_OPTIONS } from '@/workflow/layout/constants/WorkflowLayoutDefaultOptions';
export type WorkflowLayoutNode = {
id: string;
width: number;
height: number;
};
export type WorkflowLayoutEdge = {
source: string;
target: string;
};
export type WorkflowLayoutPosition = {
id: string;
position: { x: number; y: number };
};
export type WorkflowLayoutOptions = {
ranksep: number;
nodesep: number;
rankdir: string;
};
export const computeWorkflowLayout = ({
nodes,
edges,
options = WORKFLOW_LAYOUT_DEFAULT_OPTIONS,
}: {
nodes: WorkflowLayoutNode[];
edges: WorkflowLayoutEdge[];
options?: WorkflowLayoutOptions;
}): WorkflowLayoutPosition[] => {
const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
graph.setGraph({
ranksep: options.ranksep,
nodesep: options.nodesep,
rankdir: options.rankdir,
});
const nodeIds = new Set(nodes.map((node) => node.id));
nodes.forEach((node) =>
graph.setNode(node.id, {
width: node.width,
height: node.height,
}),
);
edges.forEach((edge) => {
if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) {
graph.setEdge(edge.source, edge.target);
}
});
Dagre.layout(graph);
return nodes.map((node) => {
const layoutedNode = graph.node(node.id);
const x = layoutedNode.x - layoutedNode.width / 2;
const y = layoutedNode.y - layoutedNode.height / 2;
return { id: node.id, position: { x, y } };
});
};
+1
View File
@@ -54927,6 +54927,7 @@ __metadata:
dependencies:
"@babel/preset-env": "npm:^7.26.9"
"@babel/preset-typescript": "npm:^7.24.6"
"@dagrejs/dagre": "npm:^1.1.8"
"@lingui/core": "npm:^5.9.5"
"@prettier/sync": "npm:^0.5.2"
"@sniptt/guards": "npm:^0.2.0"