vellum-workflow-server 1.9.6.post1__py3-none-any.whl → 1.10.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: vellum-workflow-server
3
- Version: 1.9.6.post1
3
+ Version: 1.10.7
4
4
  Summary:
5
5
  License: AGPL
6
6
  Requires-Python: >=3.9.0,<4
@@ -26,10 +26,10 @@ Requires-Dist: gunicorn (==23.0.0)
26
26
  Requires-Dist: orderly-set (==5.2.2)
27
27
  Requires-Dist: pebble (==5.0.7)
28
28
  Requires-Dist: pyjwt (==2.10.0)
29
- Requires-Dist: python-dotenv (==1.0.1)
29
+ Requires-Dist: python-dotenv (==1.2.1)
30
30
  Requires-Dist: retrying (==1.3.4)
31
31
  Requires-Dist: sentry-sdk[flask] (==2.20.0)
32
- Requires-Dist: vellum-ai (==1.9.6)
32
+ Requires-Dist: vellum-ai (==1.10.7)
33
33
  Description-Content-Type: text/markdown
34
34
 
35
35
  # Vellum Workflow Runner Server
@@ -5,22 +5,23 @@ workflow_server/api/healthz_view.py,sha256=itiRvBDBXncrw8Kbbc73UZLwqMAhgHOR3uSre
5
5
  workflow_server/api/status_view.py,sha256=Jah8dBAVL4uOcRfsjKAOyfVONFyk9HQjXeRfjcIqhmA,514
6
6
  workflow_server/api/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
7
  workflow_server/api/tests/test_input_display_mapping.py,sha256=drBZqMudFyB5wgiUOcMgRXz7E7ge-Qgxbstw4E4f0zE,2211
8
- workflow_server/api/tests/test_workflow_view.py,sha256=81kAHpijNp0rvb3ZjvceB5uFEriVWPeWHnK78-xoeTc,32343
9
- workflow_server/api/tests/test_workflow_view_stream_workflow_route.py,sha256=Yrp_DlLbbwZJe5WRLwdlFT17R8CQoCK9-jlQ1jUT_eM,40377
10
- workflow_server/api/workflow_view.py,sha256=XSVfHYgsy2k_QqTiue9Xx438Z4qprHbD0PeS8JI04sY,24547
8
+ workflow_server/api/tests/test_workflow_view.py,sha256=B6B8mCirt3FvpPKRP_AyzPJ199k_gwLzAcQuWRkzEfA,32343
9
+ workflow_server/api/tests/test_workflow_view_async_exec.py,sha256=eP_H2xI9SRfJdoJ6HPeynQecnxR50I_8aDCooF-YzIw,11952
10
+ workflow_server/api/tests/test_workflow_view_stream_workflow_route.py,sha256=16ZxP_nuAIP1Cg4-4z6EQttn07SRY1GkVq2m53z6XaE,42389
11
+ workflow_server/api/workflow_view.py,sha256=RiRO0Z_gCIbdcG9XX_PcB9j8Qx5K_2dXxxtkib6fezY,24601
11
12
  workflow_server/code_exec_runner.py,sha256=DLNNrinCRbnkSvlqVvSZ1wv_etI7r_kKAXNPGMj3jBk,2196
12
13
  workflow_server/config.py,sha256=I4hfTsjIbHxoSKylPCjKnrysPV0jO5nfRKwpKvEcfAE,2193
13
14
  workflow_server/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
15
  workflow_server/core/cancel_workflow.py,sha256=QcEeYUIrxq4pub-z9BlGi5fLI3gVRml-56rMCW7j5Hc,2212
15
16
  workflow_server/core/events.py,sha256=24MA66DVQuaLJJcZrS8IL1Zq4Ohi9CoouKZ5VgoH3Cs,1402
16
- workflow_server/core/executor.py,sha256=lP69l8ATeSe88DOUPIO5mmwq1iuQ-02smw7Tr471wTY,17754
17
- workflow_server/core/utils.py,sha256=si0NB4Suurc-mn8NYdn59xM9CkPrfOP1aWEVrZvifDI,1929
18
- workflow_server/core/workflow_executor_context.py,sha256=uUlFF2PIfFzIzhHS25mpvO4wO97UWqQVArg7zC2xVcM,3490
17
+ workflow_server/core/executor.py,sha256=xbySFdb9KHoqFDfiKMR77fViFVo3XEQ5ER54C1PlS8c,16948
18
+ workflow_server/core/utils.py,sha256=mecVPqQkthrC4mpop3r8J3IWnBmKbDgqfCrSagyzVEg,2021
19
+ workflow_server/core/workflow_executor_context.py,sha256=8faOdpU4cBeIbmOvg9VzD3eS5i_PKcH7tyNGzx_rehg,3899
19
20
  workflow_server/logging_config.py,sha256=Hvx1t8uhqMMinl-5qcef7ufUvzs6x14VRnCb7YZxEAg,1206
20
21
  workflow_server/server.py,sha256=pBl0OQmrLE-PbTDwTgsVmxgz_Ai3TVhFRaMnr6PX6Yk,1849
21
- workflow_server/start.py,sha256=Ams5ycqVbBorC7s6EI95BYzjpxzlo5mQbBnMNOkJS0w,2753
22
+ workflow_server/start.py,sha256=dvV8EKUH_oaTbOzNmUolF7RpkPWW8IkFwlgqOV9BhZQ,2842
22
23
  workflow_server/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
- workflow_server/utils/exit_handler.py,sha256=_FacDVi4zc3bfTA3D2mJsISePlJ8jpLrnGVo5-xZQFs,743
24
+ workflow_server/utils/exit_handler.py,sha256=PzRpzmia4Ki33sJTWjsvjD5oLP4_qfS5SZg2uXnyqxE,1767
24
25
  workflow_server/utils/log_proxy.py,sha256=nugi6fOgAYKX2X9DIc39TG366rsmmDUPoEtG3gzma_Y,3088
25
26
  workflow_server/utils/oom_killer.py,sha256=dzaqSzi0jQ3MvALwwiYIO9r6VWLa5Ln9AY6l11WEexo,3050
26
27
  workflow_server/utils/sentry.py,sha256=pmGDoaFhJwUprjP_Vmz6bETitqKQulJ0vwRP-gYb2w4,2145
@@ -30,7 +31,7 @@ workflow_server/utils/tests/test_sentry_integration.py,sha256=14PfuW8AaQNNtqLmBs
30
31
  workflow_server/utils/tests/test_system_utils.py,sha256=_4GwXvVvU5BrATxUEWwQIPg0bzQXMWBtiBmjP8MTxJM,4314
31
32
  workflow_server/utils/tests/test_utils.py,sha256=0Nq6du8o-iBtTrip9_wgHES53JSiJbVdSXaBnPobw3s,6930
32
33
  workflow_server/utils/utils.py,sha256=m7iMJtor5SQLWu7jlJw-X5Q3nmbq69BCxTMv6qnFYrA,4835
33
- vellum_workflow_server-1.9.6.post1.dist-info/METADATA,sha256=69SUWrSyCFeBTIuN7NVAhHUTUBaNtUAgz8SrHFlEOag,2273
34
- vellum_workflow_server-1.9.6.post1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
35
- vellum_workflow_server-1.9.6.post1.dist-info/entry_points.txt,sha256=uB_0yPkr7YV6RhEXzvFReUM8P4OQBlVXD6TN6eb9-oc,277
36
- vellum_workflow_server-1.9.6.post1.dist-info/RECORD,,
34
+ vellum_workflow_server-1.10.7.dist-info/METADATA,sha256=fmGtKTAbZbiiO93Cw18heCPbkQUjjicY2bEXEg2TB5U,2269
35
+ vellum_workflow_server-1.10.7.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
36
+ vellum_workflow_server-1.10.7.dist-info/entry_points.txt,sha256=uB_0yPkr7YV6RhEXzvFReUM8P4OQBlVXD6TN6eb9-oc,277
37
+ vellum_workflow_server-1.10.7.dist-info/RECORD,,
@@ -63,11 +63,11 @@ class TestNode(BaseNode):
63
63
  "comment": {"expanded": True, "value": "A test node for processing data."},
64
64
  "position": {"x": 0.0, "y": 0.0},
65
65
  },
66
- "id": "7a8b251d-f5ca-462a-b293-071d219460fb",
66
+ "id": "6f4c9178-9f46-4723-bcb7-0bd59db54eca",
67
67
  "label": "Test Node",
68
68
  "outputs": [],
69
- "ports": [{"id": "a3a0eefd-45d0-4f13-8c58-a836a9f7f9ed", "name": "default", "type": "DEFAULT"}],
70
- "trigger": {"id": "a022e36c-9852-4772-9be3-3c6c147fd811", "merge_behavior": "AWAIT_ATTRIBUTES"},
69
+ "ports": [{"id": "4394823f-79a8-4dbc-99ae-06a1df6c7408", "name": "default", "type": "DEFAULT"}],
70
+ "trigger": {"id": "07240af1-67c6-4460-b53d-53f0b0f1b90e", "merge_behavior": "AWAIT_ATTRIBUTES"},
71
71
  "type": "GENERIC",
72
72
  }
73
73
 
@@ -127,11 +127,11 @@ class SomeOtherNode(BaseNode):
127
127
  "comment": {"expanded": True, "value": "This is Some Node."},
128
128
  "position": {"x": 0.0, "y": 0.0},
129
129
  },
130
- "id": "1e559c2e-db82-41f0-9ceb-5e89b0c5a0a3",
130
+ "id": "89e84bac-5a5f-4f64-8083-7d3ebec98be1",
131
131
  "label": "Some Node",
132
132
  "outputs": [],
133
- "ports": [{"id": "48e39e97-5fd4-471e-b4f2-51d3baf06456", "name": "default", "type": "DEFAULT"}],
134
- "trigger": {"id": "e3381fb7-61fc-4c46-ae8e-51fc463b6a59", "merge_behavior": "AWAIT_ATTRIBUTES"},
133
+ "ports": [{"id": "2983ea5c-1d29-483a-b896-53098f5de4f1", "name": "default", "type": "DEFAULT"}],
134
+ "trigger": {"id": "6996efb0-5a20-4719-8835-34fe6552764a", "merge_behavior": "AWAIT_ATTRIBUTES"},
135
135
  "type": "GENERIC",
136
136
  }
137
137
 
@@ -150,11 +150,11 @@ class SomeOtherNode(BaseNode):
150
150
  "comment": {"expanded": True, "value": "This is Some Other Node."},
151
151
  "position": {"x": 0.0, "y": 0.0},
152
152
  },
153
- "id": "7aee541b-b245-4c8a-9137-3e4631d5100c",
153
+ "id": "3cdbba02-8a34-4e0f-8b94-770a944dcaa3",
154
154
  "label": "Some Other Node",
155
155
  "outputs": [],
156
- "ports": [{"id": "fb66b46a-d970-4bc9-83ea-70c154c57ddd", "name": "default", "type": "DEFAULT"}],
157
- "trigger": {"id": "13fa2714-20b3-4bc3-ab79-621a188e3bfa", "merge_behavior": "AWAIT_ATTRIBUTES"},
156
+ "ports": [{"id": "1839bde5-2ad4-4723-b21b-2c55fa833a7a", "name": "default", "type": "DEFAULT"}],
157
+ "trigger": {"id": "c36df8a8-5624-45be-99c9-826cf511a951", "merge_behavior": "AWAIT_ATTRIBUTES"},
158
158
  "type": "GENERIC",
159
159
  }
160
160
 
@@ -222,11 +222,11 @@ class HelperClass:
222
222
  "comment": {"expanded": True, "value": "Processes input data."},
223
223
  "position": {"x": 0.0, "y": 0.0},
224
224
  },
225
- "id": "f92c09f0-0434-46cb-829d-a73f801d6343",
225
+ "id": "7121bcb9-98a1-4907-bf9b-9734d773fd15",
226
226
  "label": "Processing Node",
227
227
  "outputs": [],
228
- "ports": [{"id": "abaa2984-b312-4491-b069-e689759f72c8", "name": "default", "type": "DEFAULT"}],
229
- "trigger": {"id": "35378c2b-f089-44af-ac37-efe4ea42c817", "merge_behavior": "AWAIT_ATTRIBUTES"},
228
+ "ports": [{"id": "de27da74-30e9-4e7b-95c2-92bdfc5bf042", "name": "default", "type": "DEFAULT"}],
229
+ "trigger": {"id": "e02bd85e-8b03-4b21-8b3e-f411042334ce", "merge_behavior": "AWAIT_ATTRIBUTES"},
230
230
  "type": "GENERIC",
231
231
  }
232
232
 
@@ -240,11 +240,11 @@ class HelperClass:
240
240
  "comment": {"expanded": True, "value": "Transforms data format."},
241
241
  "position": {"x": 0.0, "y": 0.0},
242
242
  },
243
- "id": "09ca32f7-c8f2-4469-97e5-1f288f85127a",
243
+ "id": "6a785cb0-f631-4f03-94c6-e82331c14c1a",
244
244
  "label": "Transformation Node",
245
245
  "outputs": [],
246
- "ports": [{"id": "88778117-fbfc-4b44-964b-5a4994aa2f24", "name": "default", "type": "DEFAULT"}],
247
- "trigger": {"id": "5d096263-7fbf-490a-83b7-e441852b5fb6", "merge_behavior": "AWAIT_ATTRIBUTES"},
246
+ "ports": [{"id": "67a13ea0-fd6b-44dc-af46-c72da06aa11f", "name": "default", "type": "DEFAULT"}],
247
+ "trigger": {"id": "08d4e317-baa8-478f-b278-99362e50e6b4", "merge_behavior": "AWAIT_ATTRIBUTES"},
248
248
  "type": "GENERIC",
249
249
  }
250
250
 
@@ -306,11 +306,11 @@ class BrokenNode(BaseNode)
306
306
  "comment": {"expanded": True, "value": "This is Some Node."},
307
307
  "position": {"x": 0.0, "y": 0.0},
308
308
  },
309
- "id": "1e559c2e-db82-41f0-9ceb-5e89b0c5a0a3",
309
+ "id": "a2706730-074b-4ea3-968a-25e68af1caed",
310
310
  "label": "Some Node",
311
311
  "outputs": [],
312
- "ports": [{"id": "48e39e97-5fd4-471e-b4f2-51d3baf06456", "name": "default", "type": "DEFAULT"}],
313
- "trigger": {"id": "e3381fb7-61fc-4c46-ae8e-51fc463b6a59", "merge_behavior": "AWAIT_ATTRIBUTES"},
312
+ "ports": [{"id": "e0ee3653-e071-4b91-9dfc-5e1dca9c665b", "name": "default", "type": "DEFAULT"}],
313
+ "trigger": {"id": "8d931b01-30ca-4c0d-b1b7-7c18379c83e6", "merge_behavior": "AWAIT_ATTRIBUTES"},
314
314
  "type": "GENERIC",
315
315
  }
316
316
 
@@ -371,12 +371,12 @@ class MyAdditionNode(BaseNode):
371
371
  "adornments": None,
372
372
  "attributes": [
373
373
  {
374
- "id": "aed3bcbb-d243-4a77-bb5e-409e9a28e868",
374
+ "id": "4223b340-447f-46c2-b35d-30ef16c5ae17",
375
375
  "name": "arg1",
376
376
  "value": None,
377
377
  },
378
378
  {
379
- "id": "9225d225-a41b-4642-8964-f28f58dcf4bf",
379
+ "id": "1de0f46a-95f6-4cd0-bb0f-e2414054d507",
380
380
  "name": "arg2",
381
381
  "value": None,
382
382
  },
@@ -387,11 +387,11 @@ class MyAdditionNode(BaseNode):
387
387
  "comment": {"expanded": True, "value": "Custom node that performs simple addition."},
388
388
  "position": {"x": 0.0, "y": 0.0},
389
389
  },
390
- "id": "195cd69d-3d2d-41e4-a432-16c433cb8d34",
390
+ "id": "2464b610-fb6d-495b-b17c-933ee147f19f",
391
391
  "label": "My Addition Node",
392
- "outputs": [{"id": "3d8e40cb-2aa8-44bd-ae6a-708a9fbc4779", "name": "result", "type": "NUMBER", "value": None}],
393
- "ports": [{"id": "9a9e4ef6-febf-4093-a515-217bbb1373db", "name": "default", "type": "DEFAULT"}],
394
- "trigger": {"id": "a5298668-d808-4a45-a62e-790943948e8a", "merge_behavior": "AWAIT_ATTRIBUTES"},
392
+ "outputs": [{"id": "f39d85c9-e7bf-45e1-bb67-f16225db0118", "name": "result", "type": "NUMBER", "value": None}],
393
+ "ports": [{"id": "bc489295-cd8a-4aa2-88bb-34446374100d", "name": "default", "type": "DEFAULT"}],
394
+ "trigger": {"id": "ff580cad-73d6-44fe-8f2c-4b8dc990ee70", "merge_behavior": "AWAIT_ATTRIBUTES"},
395
395
  "type": "GENERIC",
396
396
  "should_file_merge": True,
397
397
  }
@@ -0,0 +1,410 @@
1
+ import pytest
2
+ import logging
3
+ import time
4
+ from uuid import uuid4
5
+
6
+ from workflow_server.server import create_app
7
+ from workflow_server.utils.system_utils import get_active_process_count
8
+
9
+
10
+ @pytest.fixture(autouse=True)
11
+ def drain_background_threads():
12
+ """
13
+ Ensures background threads from previous tests complete before starting the next test.
14
+ This prevents cross-test interference in process count assertions.
15
+ """
16
+ baseline = get_active_process_count()
17
+ yield
18
+
19
+ deadline = time.time() + 15
20
+ while time.time() < deadline:
21
+ current_count = get_active_process_count()
22
+ if current_count == baseline:
23
+ break
24
+ time.sleep(0.1)
25
+
26
+
27
+ def test_async_exec_route__happy_path():
28
+ """
29
+ Tests that the async-exec route successfully accepts a valid workflow and returns immediately.
30
+ """
31
+ # GIVEN a Flask application
32
+ flask_app = create_app()
33
+
34
+ # AND a valid workflow request
35
+ span_id = uuid4()
36
+ request_body = {
37
+ "execution_id": str(span_id),
38
+ "inputs": [],
39
+ "environment_api_key": "test",
40
+ "module": "workflow",
41
+ "timeout": 360,
42
+ "files": {
43
+ "__init__.py": "",
44
+ "workflow.py": """\
45
+ from vellum.workflows import BaseWorkflow
46
+
47
+ class Workflow(BaseWorkflow):
48
+ class Outputs(BaseWorkflow.Outputs):
49
+ foo = "hello"
50
+ """,
51
+ },
52
+ }
53
+
54
+ # WHEN we make a request to the async-exec route
55
+ with flask_app.test_client() as test_client:
56
+ response = test_client.post("/workflow/async-exec", json=request_body)
57
+
58
+ # THEN we should get a 200 response
59
+ assert response.status_code == 200
60
+
61
+ # AND the response should indicate success
62
+ assert response.json == {"success": True}
63
+
64
+
65
+ def test_async_exec_route__with_inputs():
66
+ """
67
+ Tests that the async-exec route handles workflows with inputs correctly.
68
+ """
69
+ # GIVEN a Flask application
70
+ flask_app = create_app()
71
+
72
+ # AND a valid workflow request with inputs
73
+ span_id = uuid4()
74
+ request_body = {
75
+ "execution_id": str(span_id),
76
+ "inputs": [
77
+ {"name": "foo", "type": "STRING", "value": "hello"},
78
+ ],
79
+ "environment_api_key": "test",
80
+ "module": "workflow",
81
+ "timeout": 360,
82
+ "files": {
83
+ "__init__.py": "",
84
+ "workflow.py": """\
85
+ from vellum.workflows import BaseWorkflow
86
+ from vellum.workflows.state import BaseState
87
+ from .inputs import Inputs
88
+
89
+ class Workflow(BaseWorkflow[Inputs, BaseState]):
90
+ class Outputs(BaseWorkflow.Outputs):
91
+ foo = "hello"
92
+ """,
93
+ "inputs.py": """\
94
+ from vellum.workflows.inputs import BaseInputs
95
+
96
+ class Inputs(BaseInputs):
97
+ foo: str
98
+ """,
99
+ },
100
+ }
101
+
102
+ # WHEN we make a request to the async-exec route
103
+ with flask_app.test_client() as test_client:
104
+ response = test_client.post("/workflow/async-exec", json=request_body)
105
+
106
+ # THEN we should get a 200 response
107
+ assert response.status_code == 200
108
+
109
+ # AND the response should indicate success
110
+ assert response.json == {"success": True}
111
+
112
+
113
+ def test_async_exec_route__with_state():
114
+ """
115
+ Tests that the async-exec route handles workflows with state correctly.
116
+ """
117
+ # GIVEN a Flask application
118
+ flask_app = create_app()
119
+
120
+ # AND a valid workflow request with state
121
+ span_id = uuid4()
122
+ request_body = {
123
+ "execution_id": str(span_id),
124
+ "state": {"foo": "bar"},
125
+ "environment_api_key": "test",
126
+ "module": "workflow",
127
+ "timeout": 360,
128
+ "files": {
129
+ "__init__.py": "",
130
+ "workflow.py": """\
131
+ from vellum.workflows import BaseWorkflow
132
+ from vellum.workflows.inputs import BaseInputs
133
+ from .state import State
134
+
135
+ class Workflow(BaseWorkflow[BaseInputs, State]):
136
+ class Outputs(BaseWorkflow.Outputs):
137
+ foo = State.foo
138
+ """,
139
+ "state.py": """\
140
+ from vellum.workflows.state import BaseState
141
+
142
+ class State(BaseState):
143
+ foo: str
144
+ """,
145
+ },
146
+ }
147
+
148
+ # WHEN we make a request to the async-exec route
149
+ with flask_app.test_client() as test_client:
150
+ response = test_client.post("/workflow/async-exec", json=request_body)
151
+
152
+ # THEN we should get a 200 response
153
+ assert response.status_code == 200
154
+
155
+ # AND the response should indicate success
156
+ assert response.json == {"success": True}
157
+
158
+
159
+ def test_async_exec_route__invalid_context():
160
+ """
161
+ Tests that the async-exec route returns 400 for invalid request context.
162
+ """
163
+ # GIVEN a Flask application
164
+ flask_app = create_app()
165
+
166
+ # AND an invalid request missing required fields
167
+ request_body = {
168
+ "inputs": [],
169
+ }
170
+
171
+ # WHEN we make a request to the async-exec route
172
+ with flask_app.test_client() as test_client:
173
+ response = test_client.post("/workflow/async-exec", json=request_body)
174
+
175
+ # THEN we should get a 400 response
176
+ assert response.status_code == 400
177
+
178
+ # AND the response should contain error details
179
+ assert "detail" in response.json
180
+ assert "Invalid context" in response.json["detail"]
181
+
182
+
183
+ def test_async_exec_route__missing_files():
184
+ """
185
+ Tests that the async-exec route returns 400 when files are missing.
186
+ """
187
+ # GIVEN a Flask application
188
+ flask_app = create_app()
189
+
190
+ span_id = uuid4()
191
+ request_body = {
192
+ "execution_id": str(span_id),
193
+ "inputs": [],
194
+ "environment_api_key": "test",
195
+ "module": "workflow",
196
+ "timeout": 360,
197
+ }
198
+
199
+ # WHEN we make a request to the async-exec route
200
+ with flask_app.test_client() as test_client:
201
+ response = test_client.post("/workflow/async-exec", json=request_body)
202
+
203
+ # THEN we should get a 400 response
204
+ assert response.status_code == 400
205
+
206
+ # AND the response should contain error details
207
+ assert "detail" in response.json
208
+ assert "Invalid context" in response.json["detail"]
209
+
210
+
211
+ def test_async_exec_route__with_syntax_error_in_workflow():
212
+ """
213
+ Tests that the async-exec route handles workflows with syntax errors gracefully.
214
+ """
215
+ # GIVEN a Flask application
216
+ flask_app = create_app()
217
+
218
+ span_id = uuid4()
219
+ request_body = {
220
+ "execution_id": str(span_id),
221
+ "inputs": [],
222
+ "environment_api_key": "test",
223
+ "module": "workflow",
224
+ "timeout": 360,
225
+ "files": {
226
+ "__init__.py": "",
227
+ "workflow.py": """\
228
+ from vellum.workflows import BaseWorkflow
229
+
230
+ class Workflow(BaseWorkflow)
231
+ class Outputs(BaseWorkflow.Outputs):
232
+ foo = "hello"
233
+ """,
234
+ },
235
+ }
236
+
237
+ # WHEN we make a request to the async-exec route
238
+ with flask_app.test_client() as test_client:
239
+ response = test_client.post("/workflow/async-exec", json=request_body)
240
+
241
+ # THEN we should get a 200 response (async execution is accepted)
242
+ assert response.status_code == 200
243
+
244
+ # AND the response should indicate success
245
+ assert response.json == {"success": True}
246
+
247
+
248
+ def test_async_exec_route__with_invalid_inputs():
249
+ """
250
+ Tests that the async-exec route handles workflows with invalid inputs gracefully.
251
+ """
252
+ # GIVEN a Flask application
253
+ flask_app = create_app()
254
+
255
+ span_id = uuid4()
256
+ request_body = {
257
+ "execution_id": str(span_id),
258
+ "inputs": [],
259
+ "environment_api_key": "test",
260
+ "module": "workflow",
261
+ "timeout": 360,
262
+ "files": {
263
+ "__init__.py": "",
264
+ "workflow.py": """\
265
+ from vellum.workflows import BaseWorkflow
266
+ from vellum.workflows.state import BaseState
267
+ from .inputs import Inputs
268
+
269
+ class Workflow(BaseWorkflow[Inputs, BaseState]):
270
+ class Outputs(BaseWorkflow.Outputs):
271
+ foo = "hello"
272
+ """,
273
+ "inputs.py": """\
274
+ from vellum.workflows.inputs import BaseInputs
275
+
276
+ class Inputs(BaseInputs):
277
+ foo: str
278
+ """,
279
+ },
280
+ }
281
+
282
+ # WHEN we make a request to the async-exec route
283
+ with flask_app.test_client() as test_client:
284
+ response = test_client.post("/workflow/async-exec", json=request_body)
285
+
286
+ # THEN we should get a 200 response (async execution is accepted)
287
+ assert response.status_code == 200
288
+
289
+ # AND the response should indicate success
290
+ assert response.json == {"success": True}
291
+
292
+
293
+ def test_async_exec_route__background_thread_completes(caplog):
294
+ """
295
+ Verifies that the async background worker thread runs to completion.
296
+ """
297
+ # GIVEN a Flask application with log capture enabled
298
+ caplog.set_level(logging.INFO, logger="workflow_server.api.workflow_view")
299
+ flask_app = create_app()
300
+
301
+ baseline = get_active_process_count()
302
+
303
+ # AND a valid workflow request
304
+ span_id = uuid4()
305
+ request_body = {
306
+ "execution_id": str(span_id),
307
+ "inputs": [],
308
+ "environment_api_key": "test",
309
+ "module": "workflow",
310
+ "timeout": 360,
311
+ "files": {
312
+ "__init__.py": "",
313
+ "workflow.py": """\
314
+ from vellum.workflows import BaseWorkflow
315
+
316
+ class Workflow(BaseWorkflow):
317
+ class Outputs(BaseWorkflow.Outputs):
318
+ foo = "hello"
319
+ """,
320
+ },
321
+ }
322
+
323
+ # WHEN we call the async-exec route
324
+ with flask_app.test_client() as test_client:
325
+ response = test_client.post("/workflow/async-exec", json=request_body)
326
+
327
+ # THEN we get immediate acceptance
328
+ assert response.status_code == 200
329
+ assert response.json == {"success": True}
330
+
331
+ # AND the background thread should complete
332
+ completion_deadline = time.time() + 15
333
+ saw_completion_log = False
334
+ while time.time() < completion_deadline:
335
+ if any("Workflow async exec completed" in rec.message for rec in caplog.records):
336
+ saw_completion_log = True
337
+ break
338
+ time.sleep(0.1)
339
+
340
+ # THEN we should observe the completion log
341
+ assert saw_completion_log, "Did not observe background completion log within 15 seconds"
342
+
343
+ cleanup_deadline = time.time() + 15
344
+ process_count_returned = False
345
+ while time.time() < cleanup_deadline:
346
+ current_count = get_active_process_count()
347
+ if current_count == baseline:
348
+ process_count_returned = True
349
+ break
350
+ time.sleep(0.1)
351
+
352
+ current_count = get_active_process_count()
353
+ assert process_count_returned, (
354
+ f"Process count did not return to baseline within 15 seconds after completion log. "
355
+ f"Expected: {baseline}, Current: {current_count}"
356
+ )
357
+
358
+
359
+ def test_async_exec_route__background_thread_completes_on_error(caplog):
360
+ """
361
+ Verifies that the background worker completes even when the workflow fails early.
362
+ """
363
+ # GIVEN a Flask application with log capture enabled
364
+ caplog.set_level(logging.INFO, logger="workflow_server.api.workflow_view")
365
+ flask_app = create_app()
366
+
367
+ baseline = get_active_process_count()
368
+
369
+ span_id = uuid4()
370
+ request_body = {
371
+ "execution_id": str(span_id),
372
+ "inputs": [],
373
+ "environment_api_key": "test",
374
+ "module": "workflow",
375
+ "timeout": 360,
376
+ "files": {
377
+ "__init__.py": "",
378
+ "workflow.py": """\
379
+ from vellum.workflows import BaseWorkflow
380
+
381
+ class Workflow(BaseWorkflow)
382
+ class Outputs(BaseWorkflow.Outputs):
383
+ foo = "hello"
384
+ """,
385
+ },
386
+ }
387
+
388
+ # WHEN we call the async-exec route
389
+ with flask_app.test_client() as test_client:
390
+ response = test_client.post("/workflow/async-exec", json=request_body)
391
+
392
+ # THEN we get immediate acceptance
393
+ assert response.status_code == 200
394
+ assert response.json == {"success": True}
395
+
396
+ # AND the background thread should complete and clean up resources
397
+ deadline = time.time() + 15
398
+ process_count_returned = False
399
+ while time.time() < deadline:
400
+ current_count = get_active_process_count()
401
+ if current_count == baseline:
402
+ process_count_returned = True
403
+ break
404
+ time.sleep(0.1)
405
+
406
+ current_count = get_active_process_count()
407
+ assert process_count_returned, (
408
+ f"Process count did not return to baseline on error within 15 seconds. "
409
+ f"Expected: {baseline}, Current: {current_count}"
410
+ )
@@ -1241,3 +1241,72 @@ class InvalidWorkflow(BaseWorkflow):
1241
1241
  assert events[3]["name"] == "vembda.execution.fulfilled"
1242
1242
  assert events[3]["span_id"] == str(span_id)
1243
1243
  assert events[3]["body"]["exit_code"] == 0
1244
+
1245
+
1246
+ @mock.patch("workflow_server.api.workflow_view.get_is_oom_killed")
1247
+ def test_stream_workflow_route__oom_does_not_set_timed_out_flag(mock_get_is_oom_killed):
1248
+ """
1249
+ Tests that when an OOM error occurs, we don't set the timed_out flag in the vembda fulfilled event.
1250
+ """
1251
+ # GIVEN a workflow that takes some time to execute
1252
+ span_id = uuid4()
1253
+ request_body = {
1254
+ "timeout": 10,
1255
+ "execution_id": str(span_id),
1256
+ "inputs": [],
1257
+ "environment_api_key": "test",
1258
+ "module": "workflow",
1259
+ "files": {
1260
+ "__init__.py": "",
1261
+ "workflow.py": """\
1262
+ import time
1263
+
1264
+ from vellum.workflows.nodes.bases.base import BaseNode
1265
+ from vellum.workflows.workflows.base import BaseWorkflow
1266
+
1267
+
1268
+ class SlowNode(BaseNode):
1269
+ class Outputs(BaseNode.Outputs):
1270
+ value: str
1271
+
1272
+ def run(self) -> Outputs:
1273
+ time.sleep(2)
1274
+ return self.Outputs(value="hello world")
1275
+
1276
+
1277
+ class OOMWorkflow(BaseWorkflow):
1278
+ graph = SlowNode
1279
+ class Outputs(BaseWorkflow.Outputs):
1280
+ final_value = SlowNode.Outputs.value
1281
+
1282
+ """,
1283
+ },
1284
+ }
1285
+
1286
+ # WHEN we mock the OOM killer to trigger after a few checks
1287
+ call_count = [0]
1288
+
1289
+ def mock_oom_side_effect():
1290
+ call_count[0] += 1
1291
+ if call_count[0] > 3:
1292
+ return True
1293
+ return False
1294
+
1295
+ mock_get_is_oom_killed.side_effect = mock_oom_side_effect
1296
+
1297
+ # AND we call the stream route
1298
+ status_code, events = flask_stream(request_body)
1299
+
1300
+ # THEN we get a 200 response
1301
+ assert status_code == 200
1302
+
1303
+ # AND we get the expected events
1304
+ event_names = [e["name"] for e in events]
1305
+
1306
+ assert "vembda.execution.initiated" in event_names
1307
+
1308
+ # THEN the key assertion: if there's a vembda.execution.fulfilled event, it should NOT have timed_out=True
1309
+ vembda_fulfilled_event = next(e for e in events if e["name"] == "vembda.execution.fulfilled")
1310
+ assert (
1311
+ vembda_fulfilled_event["body"].get("timed_out") is not True
1312
+ ), "timed_out flag should not be set when OOM occurs"
@@ -528,6 +528,7 @@ def serialize_route() -> Response:
528
528
  files = data.get("files", {})
529
529
  workspace_api_key = data.get("workspace_api_key")
530
530
  is_new_server = data.get("is_new_server", False)
531
+ module = data.get("module")
531
532
 
532
533
  if not files:
533
534
  return Response(
@@ -540,7 +541,7 @@ def serialize_route() -> Response:
540
541
 
541
542
  # Generate a unique namespace for this serialization request
542
543
  namespace = get_random_namespace()
543
- virtual_finder = VirtualFileFinder(files, namespace)
544
+ virtual_finder = VirtualFileFinder(files, namespace, source_module=module)
544
545
 
545
546
  headers = {
546
547
  "X-Vellum-Is-New-Server": str(is_new_server).lower(),
@@ -11,7 +11,7 @@ from threading import Event as ThreadingEvent
11
11
  import time
12
12
  from traceback import format_exc
13
13
  from uuid import UUID, uuid4
14
- from typing import Any, Callable, Generator, Iterator, Optional, Tuple, Type
14
+ from typing import Any, Callable, Generator, Iterator, Optional, Tuple
15
15
 
16
16
  from vellum_ee.workflows.display.utils.events import event_enricher
17
17
  from vellum_ee.workflows.server.virtual_file_loader import VirtualFileFinder
@@ -273,32 +273,11 @@ def stream_node(
273
273
  disable_redirect: bool = True,
274
274
  ) -> Iterator[dict]:
275
275
  workflow, namespace = _create_workflow(executor_context)
276
- Node: Optional[Type[BaseNode]] = None
277
-
278
- for workflow_node in workflow.get_nodes():
279
- if executor_context.node_id and workflow_node.__id__ == executor_context.node_id:
280
- Node = workflow_node
281
- break
282
- elif (
283
- executor_context.node_module
284
- and executor_context.node_name
285
- and workflow_node.__name__ == executor_context.node_name
286
- and workflow_node.__module__ == f"{namespace}.{executor_context.node_module}"
287
- ):
288
- Node = workflow_node
289
- break
290
-
291
- if not Node:
292
- identifier = executor_context.node_id or f"{executor_context.node_module}.{executor_context.node_name}"
293
- raise WorkflowInitializationException(
294
- message=f"Node '{identifier}' not found in workflow",
295
- workflow_definition=workflow.__class__,
296
- )
297
276
 
298
277
  def call_node() -> Generator[dict[str, Any], Any, None]:
299
278
  executor_context.stream_start_time = time.time_ns()
300
279
 
301
- for event in workflow.run_node(Node, inputs=executor_context.inputs): # type: ignore[arg-type]
280
+ for event in workflow.run_node(executor_context.node_ref, inputs=executor_context.inputs):
302
281
  yield event.model_dump(mode="json")
303
282
 
304
283
  return _call_stream(
@@ -359,7 +338,9 @@ def _call_stream(
359
338
  def _create_workflow(executor_context: BaseExecutorContext) -> Tuple[BaseWorkflow, str]:
360
339
  namespace = _get_file_namespace(executor_context)
361
340
  if namespace != LOCAL_WORKFLOW_MODULE:
362
- sys.meta_path.append(VirtualFileFinder(executor_context.files, namespace))
341
+ sys.meta_path.append(
342
+ VirtualFileFinder(executor_context.files, namespace, source_module=executor_context.module)
343
+ )
363
344
 
364
345
  workflow_context = _create_workflow_context(executor_context)
365
346
  Workflow = BaseWorkflow.load_from_module(namespace)
@@ -2,6 +2,7 @@ from datetime import datetime
2
2
  from uuid import uuid4
3
3
  from typing import Optional
4
4
 
5
+ from workflow_server.config import IS_ASYNC_MODE
5
6
  from workflow_server.core.events import VembdaExecutionFulfilledBody, VembdaExecutionFulfilledEvent
6
7
  from workflow_server.core.workflow_executor_context import BaseExecutorContext
7
8
 
@@ -46,6 +47,9 @@ def serialize_vembda_rejected_event(
46
47
 
47
48
 
48
49
  def is_events_emitting_enabled(executor_context: Optional[BaseExecutorContext]) -> bool:
50
+ if IS_ASYNC_MODE:
51
+ return True
52
+
49
53
  if not executor_context:
50
54
  return False
51
55
 
@@ -3,7 +3,7 @@ from functools import cached_property
3
3
  import os
4
4
  import time
5
5
  from uuid import UUID
6
- from typing import Any, Optional
6
+ from typing import Any, Optional, Union
7
7
  from typing_extensions import Self
8
8
 
9
9
  from flask import has_request_context, request
@@ -91,6 +91,18 @@ class NodeExecutorContext(BaseExecutorContext):
91
91
  node_module: Optional[str] = None
92
92
  node_name: Optional[str] = None
93
93
 
94
+ @property
95
+ def node_ref(self) -> Union[UUID, str]:
96
+ """
97
+ Returns the node reference for use with workflow.run_node().
98
+
99
+ Returns node_id if it exists, otherwise returns the combination
100
+ of node_module and node_name as a fully qualified string.
101
+ """
102
+ if self.node_id:
103
+ return self.node_id
104
+ return f"{self.node_module}.{self.node_name}"
105
+
94
106
  @model_validator(mode="after")
95
107
  def validate_node_identification(self) -> Self:
96
108
  if not self.node_id and not (self.node_module and self.node_name):
workflow_server/start.py CHANGED
@@ -64,8 +64,8 @@ def start() -> None:
64
64
  "workers": int(os.getenv("GUNICORN_WORKERS", 2)),
65
65
  "threads": int(os.getenv("GUNICORN_THREADS", 9 if ENABLE_PROCESS_WRAPPER else 6)),
66
66
  # Aggressively try to avoid memory leaks when using non process mode
67
- "max_requests": 120 if ENABLE_PROCESS_WRAPPER else 20,
68
- "max_requests_jitter": 30 if ENABLE_PROCESS_WRAPPER else 10,
67
+ "max_requests": int(os.getenv("GUNICORN_MAX_REQUESTS", 120 if ENABLE_PROCESS_WRAPPER else 20)),
68
+ "max_requests_jitter": int(os.getenv("GUNICORN_MAX_REQUESTS_JITTER", 30 if ENABLE_PROCESS_WRAPPER else 10)),
69
69
  "worker_class": "gthread",
70
70
  "timeout": max_workflow_runtime_seconds,
71
71
  "logger_class": CustomGunicornLogger,
@@ -1,15 +1,43 @@
1
+ from datetime import datetime
1
2
  import logging
2
3
  import multiprocessing
3
4
  import signal
5
+ from time import sleep
4
6
  from typing import Any
5
7
 
8
+ from workflow_server.config import IS_ASYNC_MODE, is_development
9
+ from workflow_server.utils.system_utils import get_active_process_count
10
+
6
11
  logger = logging.getLogger(__name__)
7
12
  process_killed_switch = multiprocessing.Event()
8
13
 
9
14
 
15
+ def _wait_for_workers() -> None:
16
+ # Would be annoying to have this on for dev since would prevent reload restarts. Also disabling this
17
+ # for non async mode for now since it shouldn't be needed anyway cus we keep the requests open.
18
+ if is_development() and not IS_ASYNC_MODE:
19
+ return
20
+
21
+ start_time = datetime.now()
22
+ loops = 0
23
+
24
+ while get_active_process_count() > 0:
25
+ if loops % 30 == 0:
26
+ logger.info("Waiting for workflow processes to finish...")
27
+
28
+ # TODO needa pass in max workflow time here for VPC
29
+ if (datetime.now() - start_time).total_seconds() > 1800:
30
+ logger.warning("Max elapsed time waiting for workflow processes to complete exceeded, shutting down")
31
+ exit(1)
32
+
33
+ sleep(1)
34
+ loops += 1
35
+
36
+
10
37
  def gunicorn_exit_handler(_worker: Any) -> None:
38
+ logger.info("Received gunicorn kill signal")
11
39
  process_killed_switch.set()
12
- logger.warning("Received gunicorn kill signal")
40
+ _wait_for_workers()
13
41
 
14
42
 
15
43
  def exit_handler(_signal: int, _frame: Any) -> None:
@@ -19,6 +47,7 @@ def exit_handler(_signal: int, _frame: Any) -> None:
19
47
  """
20
48
  process_killed_switch.set()
21
49
  logger.warning("Received kill signal")
50
+ _wait_for_workers()
22
51
  exit(1)
23
52
 
24
53