Skip to content

Commit 4c09c35

Browse files
[APO-2321] Make entrypoint node optional in Python SDK serialization (#3346)
* [APO-2321] Make entrypoint node optional in Python SDK serialization Co-Authored-By: [email protected] <[email protected]> * Fix: restore non_trigger_entrypoint_nodes variable for edge generation Co-Authored-By: [email protected] <[email protected]> * Fix: backfill entrypoint defaults and skip ENTRYPOINT for trigger-only workflows - Backfill entrypoint IDs in _generate_workflow_meta_display when overrides omit them - Skip ENTRYPOINT node when all graph branches are sourced from triggers - Update tests to match new spec for IntegrationTrigger-only workflows Co-Authored-By: [email protected] <[email protected]> * Fix: rename display_data variables to avoid mypy type inference issues Co-Authored-By: [email protected] <[email protected]> * Add regression test for partial WorkflowMetaDisplay override Co-Authored-By: [email protected] <[email protected]> * Fix mypy type errors in regression test Co-Authored-By: [email protected] <[email protected]> * Fix duplicate edge IDs for multiple triggers targeting same node Co-Authored-By: [email protected] <[email protected]> * Update snapshot for simple_manual_trigger_workflow edge ID Co-Authored-By: [email protected] <[email protected]> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: [email protected] <[email protected]>
1 parent 9794fb8 commit 4c09c35

File tree

7 files changed

+281
-80
lines changed

7 files changed

+281
-80
lines changed

ee/codegen_integration/fixtures/simple_manual_trigger_workflow/display_data/simple_manual_trigger_workflow.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@
154154
],
155155
"edges": [
156156
{
157-
"id": "cc598260-9e77-4774-99f1-b9152d925217",
157+
"id": "3f69f314-b59e-462e-8d55-f75bf2f955dc",
158158
"source_node_id": "b3c8ab56-001f-4157-bbc2-4a7fe5ebf8c6",
159159
"source_handle_id": "eba8fd73-57ab-4d7b-8f75-b54dbe5fc8ba",
160160
"target_node_id": "67c2abb9-6c61-47d6-b222-49fd5442ba8f",

ee/vellum_ee/workflows/display/base.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,9 @@ class WorkflowDisplayData(UniversalBaseModel):
4949

5050
@dataclass
5151
class WorkflowMetaDisplay:
52-
entrypoint_node_id: UUID
53-
entrypoint_node_source_handle_id: UUID
54-
entrypoint_node_display: NodeDisplayData = Field(default_factory=NodeDisplayData)
52+
entrypoint_node_id: Optional[UUID] = None
53+
entrypoint_node_source_handle_id: Optional[UUID] = None
54+
entrypoint_node_display: Optional[NodeDisplayData] = None
5555
display_data: WorkflowDisplayData = field(default_factory=WorkflowDisplayData)
5656

5757
@classmethod

ee/vellum_ee/workflows/display/tests/workflow_serialization/test_integration_trigger_serialization.py

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,9 @@ class TestWorkflow(BaseWorkflow[BaseInputs, BaseState]):
187187

188188

189189
def test_integration_trigger_no_entrypoint_node():
190-
"""IntegrationTrigger workflows now create ENTRYPOINT nodes and route edges through them."""
190+
"""IntegrationTrigger-only workflows should NOT have an ENTRYPOINT node when all branches are trigger-sourced."""
191191

192+
# GIVEN an IntegrationTrigger workflow where all branches are sourced from the trigger
192193
class SlackMessageTrigger(IntegrationTrigger):
193194
message: str
194195

@@ -203,39 +204,32 @@ class ProcessNode(BaseNode):
203204
class TestWorkflow(BaseWorkflow[BaseInputs, BaseState]):
204205
graph = SlackMessageTrigger >> ProcessNode
205206

207+
# WHEN we serialize the workflow
206208
result = get_workflow_display(workflow_class=TestWorkflow).serialize()
207209

208-
# Get trigger ID
210+
# THEN the trigger should be serialized
209211
triggers = result["triggers"]
210212
assert isinstance(triggers, list)
211213
assert len(triggers) == 1
212214
trigger = triggers[0]
213215
assert isinstance(trigger, dict)
214216
trigger_id = trigger["id"]
215217

216-
# Verify ENTRYPOINT node exists
218+
# AND there should be NO ENTRYPOINT node (all branches are trigger-sourced)
217219
workflow_raw_data = result["workflow_raw_data"]
218220
assert isinstance(workflow_raw_data, dict)
219221
nodes = workflow_raw_data["nodes"]
220222
assert isinstance(nodes, list)
221223
entrypoint_nodes = [n for n in nodes if isinstance(n, dict) and n.get("type") == "ENTRYPOINT"]
222-
assert len(entrypoint_nodes) == 1, "IntegrationTrigger workflows should have an ENTRYPOINT node"
223-
224-
entrypoint_node = entrypoint_nodes[0]
225-
assert isinstance(entrypoint_node, dict)
226-
entrypoint_node_id = entrypoint_node["id"]
224+
assert len(entrypoint_nodes) == 0, "IntegrationTrigger-only workflows should NOT have an ENTRYPOINT node"
227225

226+
# AND edges should use trigger ID as source_node_id
228227
edges = workflow_raw_data["edges"]
229228
assert isinstance(edges, list)
230-
entrypoint_edges = [e for e in edges if isinstance(e, dict) and e.get("source_node_id") == entrypoint_node_id]
231-
assert len(entrypoint_edges) == 0
232-
233-
# Verify edges use trigger ID as sourceNodeId (not ENTRYPOINT)
234229
trigger_edges = [e for e in edges if isinstance(e, dict) and e.get("source_node_id") == trigger_id]
235230
assert len(trigger_edges) > 0, "Should have edges from trigger ID"
236231

237-
# Verify the edge connects trigger to first node
238-
# ProcessNode should be the only non-terminal, non-entrypoint node
232+
# AND the edge should connect trigger to the process node
239233
process_nodes = [n for n in nodes if isinstance(n, dict) and n.get("type") not in ("TERMINAL", "ENTRYPOINT")]
240234
assert len(process_nodes) > 0, "Should have at least one process node"
241235
process_node = process_nodes[0]

ee/vellum_ee/workflows/display/tests/workflow_serialization/test_integration_trigger_with_entrypoint_node_id.py

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,13 @@
1616
def test_integration_trigger_with_explicit_entrypoint_node_id():
1717
"""
1818
Tests that a workflow with an IntegrationTrigger and explicit entrypoint_node_id
19-
creates an ENTRYPOINT node using the explicit ID (not the trigger's ID).
19+
does NOT create an ENTRYPOINT node when all branches are trigger-sourced.
20+
21+
Even with explicit IDs provided, if all graph branches originate from triggers,
22+
the ENTRYPOINT node should be skipped.
2023
"""
2124

25+
# GIVEN an IntegrationTrigger workflow with explicit entrypoint_node_id
2226
class SlackMessageTrigger(IntegrationTrigger):
2327
message: str
2428

@@ -53,8 +57,10 @@ class TestWorkflowDisplay(BaseWorkflowDisplay[TestWorkflow]):
5357
)
5458
}
5559

60+
# WHEN we serialize the workflow
5661
result: dict = get_workflow_display(workflow_class=TestWorkflow).serialize()
5762

63+
# THEN the trigger should be serialized
5864
assert "workflow_raw_data" in result
5965
workflow_raw_data = result["workflow_raw_data"]
6066
assert isinstance(workflow_raw_data, dict)
@@ -67,22 +73,19 @@ class TestWorkflowDisplay(BaseWorkflowDisplay[TestWorkflow]):
6773
assert isinstance(trigger, dict)
6874
trigger_id = trigger["id"]
6975

76+
# AND there should be NO ENTRYPOINT node (all branches are trigger-sourced)
7077
nodes = workflow_raw_data["nodes"]
7178
assert isinstance(nodes, list)
7279

7380
entrypoint_nodes = [n for n in nodes if isinstance(n, dict) and n.get("type") == "ENTRYPOINT"]
7481

75-
assert (
76-
len(entrypoint_nodes) == 1
77-
), "IntegrationTrigger workflows with explicit entrypoint_node_id should create an ENTRYPOINT node"
78-
79-
entrypoint_node = entrypoint_nodes[0]
80-
assert isinstance(entrypoint_node, dict)
81-
entrypoint_node_id = entrypoint_node["id"]
82-
83-
assert entrypoint_node_id == str(explicit_entrypoint_node_id), (
84-
f"Entrypoint node ID should be {explicit_entrypoint_node_id} from workflow_display, "
85-
f"not {trigger_id} from trigger"
82+
assert len(entrypoint_nodes) == 0, (
83+
"IntegrationTrigger-only workflows should NOT create an ENTRYPOINT node "
84+
"even with explicit entrypoint_node_id, since all branches are trigger-sourced"
8685
)
8786

88-
assert entrypoint_node_id != trigger_id, "Entrypoint node should not use the trigger's ID"
87+
# AND edges should use trigger ID as source_node_id
88+
edges = workflow_raw_data["edges"]
89+
assert isinstance(edges, list)
90+
trigger_edges = [e for e in edges if isinstance(e, dict) and e.get("source_node_id") == trigger_id]
91+
assert len(trigger_edges) > 0, "Should have edges from trigger ID"

ee/vellum_ee/workflows/display/tests/workflow_serialization/test_multi_trigger_same_node_serialization.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,114 @@ class TestWorkflow(BaseWorkflow[BaseInputs, BaseState]):
9797
assert slack_edge is not None, (
9898
f"Should have edge from Slack trigger ({slack_trigger_id}) " f"to ProcessNode ({process_node_id})"
9999
)
100+
101+
102+
def test_two_integration_triggers_same_node_unique_edge_ids():
103+
"""
104+
Tests that when two IntegrationTriggers point to the same node,
105+
each trigger edge gets a unique ID (not duplicates).
106+
"""
107+
108+
# GIVEN two different IntegrationTriggers
109+
class SlackMessageTrigger(IntegrationTrigger):
110+
message: str
111+
112+
class Config(IntegrationTrigger.Config):
113+
provider = VellumIntegrationProviderType.COMPOSIO
114+
integration_name = "SLACK"
115+
slug = "slack_new_message"
116+
117+
class GithubPRTrigger(IntegrationTrigger):
118+
pr_title: str
119+
120+
class Config(IntegrationTrigger.Config):
121+
provider = VellumIntegrationProviderType.COMPOSIO
122+
integration_name = "GITHUB"
123+
slug = "github_pr_opened"
124+
125+
class ProcessNode(BaseNode):
126+
pass
127+
128+
# AND a workflow where both triggers point to the same node
129+
class TestWorkflow(BaseWorkflow[BaseInputs, BaseState]):
130+
graph = {
131+
SlackMessageTrigger >> ProcessNode,
132+
GithubPRTrigger >> ProcessNode,
133+
}
134+
135+
# WHEN we serialize the workflow
136+
result = get_workflow_display(workflow_class=TestWorkflow).serialize()
137+
138+
# THEN we should have two triggers
139+
triggers = result["triggers"]
140+
assert isinstance(triggers, list)
141+
assert len(triggers) == 2
142+
143+
def get_integration_name(trigger: dict) -> str:
144+
exec_config = trigger.get("exec_config")
145+
if isinstance(exec_config, dict):
146+
name = exec_config.get("integration_name")
147+
return str(name) if name else ""
148+
return ""
149+
150+
slack_trigger = next(
151+
(t for t in triggers if isinstance(t, dict) and get_integration_name(t) == "SLACK"),
152+
None,
153+
)
154+
assert slack_trigger is not None
155+
assert isinstance(slack_trigger, dict)
156+
slack_trigger_id = slack_trigger["id"]
157+
158+
github_trigger = next(
159+
(t for t in triggers if isinstance(t, dict) and get_integration_name(t) == "GITHUB"),
160+
None,
161+
)
162+
assert github_trigger is not None
163+
assert isinstance(github_trigger, dict)
164+
github_trigger_id = github_trigger["id"]
165+
166+
# AND we should have edges from both triggers
167+
workflow_raw_data = result["workflow_raw_data"]
168+
assert isinstance(workflow_raw_data, dict)
169+
edges = workflow_raw_data["edges"]
170+
assert isinstance(edges, list)
171+
172+
nodes = workflow_raw_data["nodes"]
173+
assert isinstance(nodes, list)
174+
process_nodes = [n for n in nodes if isinstance(n, dict) and n.get("type") not in ("TERMINAL", "ENTRYPOINT")]
175+
assert len(process_nodes) > 0
176+
process_node = process_nodes[0]
177+
assert isinstance(process_node, dict)
178+
process_node_id = process_node["id"]
179+
180+
slack_edge = next(
181+
(
182+
e
183+
for e in edges
184+
if isinstance(e, dict)
185+
and e.get("source_node_id") == slack_trigger_id
186+
and e.get("target_node_id") == process_node_id
187+
),
188+
None,
189+
)
190+
assert slack_edge is not None, "Should have edge from Slack trigger to ProcessNode"
191+
assert isinstance(slack_edge, dict)
192+
193+
github_edge = next(
194+
(
195+
e
196+
for e in edges
197+
if isinstance(e, dict)
198+
and e.get("source_node_id") == github_trigger_id
199+
and e.get("target_node_id") == process_node_id
200+
),
201+
None,
202+
)
203+
assert github_edge is not None, "Should have edge from GitHub trigger to ProcessNode"
204+
assert isinstance(github_edge, dict)
205+
206+
# AND the edge IDs should be unique (not duplicates)
207+
assert slack_edge["id"] != github_edge["id"], (
208+
f"Edge IDs should be unique but both are {slack_edge['id']}. "
209+
"Multiple triggers targeting the same node should have distinct edge IDs."
210+
)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Tests for partial WorkflowMetaDisplay override serialization."""
2+
3+
from vellum.workflows import BaseWorkflow
4+
from vellum.workflows.inputs.base import BaseInputs
5+
from vellum.workflows.nodes.bases.base import BaseNode
6+
from vellum.workflows.state.base import BaseState
7+
from vellum_ee.workflows.display.base import WorkflowDisplayData, WorkflowDisplayDataViewport, WorkflowMetaDisplay
8+
from vellum_ee.workflows.display.workflows.base_workflow_display import BaseWorkflowDisplay
9+
from vellum_ee.workflows.display.workflows.get_vellum_workflow_display_class import get_workflow_display
10+
11+
12+
def test_triggerless_workflow_with_partial_display_override_creates_entrypoint():
13+
"""
14+
Tests that a triggerless workflow with partial WorkflowMetaDisplay overrides
15+
(only display_data set) still creates an ENTRYPOINT node with backfilled IDs.
16+
"""
17+
18+
# GIVEN a simple triggerless workflow
19+
class ProcessNode(BaseNode):
20+
pass
21+
22+
class TestWorkflow(BaseWorkflow[BaseInputs, BaseState]):
23+
graph = ProcessNode
24+
25+
# AND a display class that only overrides display_data (viewport), not entrypoint IDs
26+
class TestWorkflowDisplay(BaseWorkflowDisplay[TestWorkflow]):
27+
workflow_display = WorkflowMetaDisplay(
28+
display_data=WorkflowDisplayData(viewport=WorkflowDisplayDataViewport(x=100.0, y=200.0, zoom=1.5))
29+
)
30+
31+
# WHEN we serialize the workflow
32+
result = get_workflow_display(workflow_class=TestWorkflow).serialize()
33+
34+
# THEN the workflow should have an ENTRYPOINT node
35+
workflow_raw_data = result["workflow_raw_data"]
36+
assert isinstance(workflow_raw_data, dict)
37+
nodes = workflow_raw_data["nodes"]
38+
assert isinstance(nodes, list)
39+
entrypoint_nodes = [n for n in nodes if isinstance(n, dict) and n.get("type") == "ENTRYPOINT"]
40+
assert len(entrypoint_nodes) == 1, "Triggerless workflow with partial overrides should have an ENTRYPOINT node"
41+
42+
# AND the ENTRYPOINT node should have valid IDs (not None)
43+
entrypoint_node = entrypoint_nodes[0]
44+
assert isinstance(entrypoint_node, dict)
45+
assert entrypoint_node["id"] is not None, "ENTRYPOINT node should have a non-None id"
46+
entrypoint_data = entrypoint_node["data"]
47+
assert isinstance(entrypoint_data, dict)
48+
source_handle_id = entrypoint_data["source_handle_id"]
49+
assert source_handle_id is not None, "ENTRYPOINT node should have a non-None source_handle_id"
50+
51+
# AND there should be an edge from ENTRYPOINT to the process node
52+
edges = workflow_raw_data["edges"]
53+
assert isinstance(edges, list)
54+
entrypoint_edges = [e for e in edges if isinstance(e, dict) and e.get("source_node_id") == entrypoint_node["id"]]
55+
assert len(entrypoint_edges) > 0, "Should have edges from ENTRYPOINT node"

0 commit comments

Comments
 (0)