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
@@ -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,