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:
+2
@@ -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,
|
||||
|
||||
+55
-1
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+7
-32
@@ -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,
|
||||
|
||||
+8
-8
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user