vellum-workflow-server 1.9.7.post1__tar.gz → 1.12.0.post1__tar.gz

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.
Files changed (36) hide show
  1. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/PKG-INFO +7 -4
  2. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/README.md +3 -1
  3. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/pyproject.toml +4 -3
  4. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/api/tests/test_workflow_view.py +9 -1
  5. vellum_workflow_server-1.12.0.post1/src/workflow_server/api/tests/test_workflow_view_async_exec.py +410 -0
  6. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/api/tests/test_workflow_view_stream_workflow_route.py +146 -1
  7. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/api/workflow_view.py +98 -11
  8. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/code_exec_runner.py +4 -3
  9. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/core/cancel_workflow.py +11 -7
  10. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/core/executor.py +7 -25
  11. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/core/utils.py +4 -0
  12. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/core/workflow_executor_context.py +14 -1
  13. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/start.py +2 -2
  14. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/utils/utils.py +9 -0
  15. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/__init__.py +0 -0
  16. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/api/__init__.py +0 -0
  17. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/api/auth_middleware.py +0 -0
  18. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/api/healthz_view.py +0 -0
  19. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/api/status_view.py +0 -0
  20. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/api/tests/__init__.py +0 -0
  21. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/api/tests/test_input_display_mapping.py +0 -0
  22. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/config.py +0 -0
  23. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/core/__init__.py +0 -0
  24. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/core/events.py +0 -0
  25. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/logging_config.py +0 -0
  26. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/server.py +0 -0
  27. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/utils/__init__.py +0 -0
  28. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/utils/exit_handler.py +0 -0
  29. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/utils/log_proxy.py +0 -0
  30. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/utils/oom_killer.py +0 -0
  31. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/utils/sentry.py +0 -0
  32. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/utils/system_utils.py +0 -0
  33. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/utils/tests/__init__.py +0 -0
  34. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/utils/tests/test_sentry_integration.py +0 -0
  35. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/utils/tests/test_system_utils.py +0 -0
  36. {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/utils/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: vellum-workflow-server
3
- Version: 1.9.7.post1
3
+ Version: 1.12.0.post1
4
4
  Summary:
5
5
  License: AGPL
6
6
  Requires-Python: >=3.9.0,<4
@@ -24,18 +24,21 @@ Requires-Dist: cryptography (==43.0.3)
24
24
  Requires-Dist: flask (==2.3.3)
25
25
  Requires-Dist: gunicorn (==23.0.0)
26
26
  Requires-Dist: orderly-set (==5.2.2)
27
+ Requires-Dist: orjson (==3.11.4)
27
28
  Requires-Dist: pebble (==5.0.7)
28
29
  Requires-Dist: pyjwt (==2.10.0)
29
- Requires-Dist: python-dotenv (==1.0.1)
30
+ Requires-Dist: python-dotenv (==1.2.1)
30
31
  Requires-Dist: retrying (==1.3.4)
31
32
  Requires-Dist: sentry-sdk[flask] (==2.20.0)
32
- Requires-Dist: vellum-ai (==1.9.7)
33
+ Requires-Dist: vellum-ai (==1.12.0)
33
34
  Description-Content-Type: text/markdown
34
35
 
35
36
  # Vellum Workflow Runner Server
37
+
36
38
  This package is meant for installing on container images in order to use custom docker images when using Vellum Workflows.
37
39
 
38
40
  ## Example Dockerfile Usage:
41
+
39
42
  ```
40
43
  FROM python:3.11.6-slim-bookworm
41
44
 
@@ -48,7 +51,6 @@ RUN pip install --upgrade pip
48
51
  RUN pip --no-cache-dir install vellum-workflow-server==0.13.2
49
52
 
50
53
  ENV PYTHONUNBUFFERED 1
51
- ENV PYTHONDONTWRITEBYTECODE 1
52
54
  COPY ./base-image/code_exec_entrypoint.sh .
53
55
  RUN chmod +x /code_exec_entrypoint.sh
54
56
 
@@ -56,5 +58,6 @@ CMD ["vellum_start_server"]
56
58
  ```
57
59
 
58
60
  ## Skipping Publishes
61
+
59
62
  If you wish to automatically skip publishing a new version when merging to main you can add a [skip-publish] to your commit message. This is useful if your changes are not time sensitive and can just go out with the next release. This avoids causing new services being created causing extra cold starts for our customers and also keeps our public versioning more tidy.
60
63
 
@@ -1,7 +1,9 @@
1
1
  # Vellum Workflow Runner Server
2
+
2
3
  This package is meant for installing on container images in order to use custom docker images when using Vellum Workflows.
3
4
 
4
5
  ## Example Dockerfile Usage:
6
+
5
7
  ```
6
8
  FROM python:3.11.6-slim-bookworm
7
9
 
@@ -14,7 +16,6 @@ RUN pip install --upgrade pip
14
16
  RUN pip --no-cache-dir install vellum-workflow-server==0.13.2
15
17
 
16
18
  ENV PYTHONUNBUFFERED 1
17
- ENV PYTHONDONTWRITEBYTECODE 1
18
19
  COPY ./base-image/code_exec_entrypoint.sh .
19
20
  RUN chmod +x /code_exec_entrypoint.sh
20
21
 
@@ -22,4 +23,5 @@ CMD ["vellum_start_server"]
22
23
  ```
23
24
 
24
25
  ## Skipping Publishes
26
+
25
27
  If you wish to automatically skip publishing a new version when merging to main you can add a [skip-publish] to your commit message. This is useful if your changes are not time sensitive and can just go out with the next release. This avoids causing new services being created causing extra cold starts for our customers and also keeps our public versioning more tidy.
@@ -3,7 +3,7 @@ name = "vellum-workflow-server"
3
3
 
4
4
  [tool.poetry]
5
5
  name = "vellum-workflow-server"
6
- version = "1.9.7.post1"
6
+ version = "1.12.0.post1"
7
7
  description = ""
8
8
  readme = "README.md"
9
9
  authors = []
@@ -45,8 +45,9 @@ flask = "2.3.3"
45
45
  orderly-set = "5.2.2"
46
46
  pebble = "5.0.7"
47
47
  gunicorn = "23.0.0"
48
- vellum-ai = "1.9.7"
49
- python-dotenv = "1.0.1"
48
+ orjson = "3.11.4"
49
+ vellum-ai = "1.12.0"
50
+ python-dotenv = "1.2.1"
50
51
  retrying = "1.3.4"
51
52
  sentry-sdk = {extras = ["flask"], version = "2.20.0"}
52
53
 
@@ -389,7 +389,15 @@ class MyAdditionNode(BaseNode):
389
389
  },
390
390
  "id": "2464b610-fb6d-495b-b17c-933ee147f19f",
391
391
  "label": "My Addition Node",
392
- "outputs": [{"id": "f39d85c9-e7bf-45e1-bb67-f16225db0118", "name": "result", "type": "NUMBER", "value": None}],
392
+ "outputs": [
393
+ {
394
+ "id": "f39d85c9-e7bf-45e1-bb67-f16225db0118",
395
+ "name": "result",
396
+ "type": "NUMBER",
397
+ "value": None,
398
+ "schema": {"type": "integer"},
399
+ }
400
+ ],
393
401
  "ports": [{"id": "bc489295-cd8a-4aa2-88bb-34446374100d", "name": "default", "type": "DEFAULT"}],
394
402
  "trigger": {"id": "ff580cad-73d6-44fe-8f2c-4b8dc990ee70", "merge_behavior": "AWAIT_ATTRIBUTES"},
395
403
  "type": "GENERIC",
@@ -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
+ )
@@ -549,7 +549,10 @@ class Inputs(BaseInputs):
549
549
  # AND the third event should be workflow execution rejected
550
550
  assert events[2]["name"] == "workflow.execution.rejected"
551
551
  assert events[1]["span_id"] == events[2]["span_id"]
552
- assert "Required input variables foo should have defined value" in events[2]["body"]["error"]["message"]
552
+ actual_error_message = events[2]["body"]["error"]["message"]
553
+ assert "Required input variables" in actual_error_message
554
+ assert "foo" in actual_error_message
555
+ assert "should have defined value" in actual_error_message
553
556
 
554
557
  # AND the fourth event should be vembda execution fulfilled
555
558
  assert events[3]["name"] == "vembda.execution.fulfilled"
@@ -1241,3 +1244,145 @@ class InvalidWorkflow(BaseWorkflow):
1241
1244
  assert events[3]["name"] == "vembda.execution.fulfilled"
1242
1245
  assert events[3]["span_id"] == str(span_id)
1243
1246
  assert events[3]["body"]["exit_code"] == 0
1247
+
1248
+
1249
+ @mock.patch("workflow_server.api.workflow_view.get_is_oom_killed")
1250
+ def test_stream_workflow_route__oom_does_not_set_timed_out_flag(mock_get_is_oom_killed):
1251
+ """
1252
+ Tests that when an OOM error occurs, we don't set the timed_out flag in the vembda fulfilled event.
1253
+ """
1254
+ # GIVEN a workflow that takes some time to execute
1255
+ span_id = uuid4()
1256
+ request_body = {
1257
+ "timeout": 10,
1258
+ "execution_id": str(span_id),
1259
+ "inputs": [],
1260
+ "environment_api_key": "test",
1261
+ "module": "workflow",
1262
+ "files": {
1263
+ "__init__.py": "",
1264
+ "workflow.py": """\
1265
+ import time
1266
+
1267
+ from vellum.workflows.nodes.bases.base import BaseNode
1268
+ from vellum.workflows.workflows.base import BaseWorkflow
1269
+
1270
+
1271
+ class SlowNode(BaseNode):
1272
+ class Outputs(BaseNode.Outputs):
1273
+ value: str
1274
+
1275
+ def run(self) -> Outputs:
1276
+ time.sleep(2)
1277
+ return self.Outputs(value="hello world")
1278
+
1279
+
1280
+ class OOMWorkflow(BaseWorkflow):
1281
+ graph = SlowNode
1282
+ class Outputs(BaseWorkflow.Outputs):
1283
+ final_value = SlowNode.Outputs.value
1284
+
1285
+ """,
1286
+ },
1287
+ }
1288
+
1289
+ # WHEN we mock the OOM killer to trigger after a few checks
1290
+ call_count = [0]
1291
+
1292
+ def mock_oom_side_effect():
1293
+ call_count[0] += 1
1294
+ if call_count[0] > 3:
1295
+ return True
1296
+ return False
1297
+
1298
+ mock_get_is_oom_killed.side_effect = mock_oom_side_effect
1299
+
1300
+ # AND we call the stream route
1301
+ status_code, events = flask_stream(request_body)
1302
+
1303
+ # THEN we get a 200 response
1304
+ assert status_code == 200
1305
+
1306
+ # AND we get the expected events
1307
+ event_names = [e["name"] for e in events]
1308
+
1309
+ assert "vembda.execution.initiated" in event_names
1310
+
1311
+ # THEN the key assertion: if there's a vembda.execution.fulfilled event, it should NOT have timed_out=True
1312
+ vembda_fulfilled_event = next(e for e in events if e["name"] == "vembda.execution.fulfilled")
1313
+ assert (
1314
+ vembda_fulfilled_event["body"].get("timed_out") is not True
1315
+ ), "timed_out flag should not be set when OOM occurs"
1316
+
1317
+
1318
+ @mock.patch("workflow_server.api.workflow_view.ENABLE_PROCESS_WRAPPER", False)
1319
+ def test_stream_workflow_route__client_disconnect_emits_rejected_event():
1320
+ """
1321
+ Tests that when a client disconnects mid-stream (GeneratorExit), we emit a workflow execution
1322
+ rejected event to the events.create API.
1323
+ """
1324
+ # GIVEN a valid request body for a workflow that yields multiple events
1325
+ span_id = uuid4()
1326
+ trace_id = uuid4()
1327
+ request_body = {
1328
+ "timeout": 360,
1329
+ "execution_id": str(span_id),
1330
+ "execution_context": {
1331
+ "trace_id": str(trace_id),
1332
+ },
1333
+ "inputs": [],
1334
+ "environment_api_key": "test",
1335
+ "module": "workflow",
1336
+ "files": {
1337
+ "__init__.py": "",
1338
+ "workflow.py": """\
1339
+ from vellum.workflows import BaseWorkflow
1340
+
1341
+ class Workflow(BaseWorkflow):
1342
+ class Outputs(BaseWorkflow.Outputs):
1343
+ foo = "hello"
1344
+ """,
1345
+ },
1346
+ }
1347
+
1348
+ # AND a mock to capture events.create calls
1349
+ events_create_calls = []
1350
+
1351
+ def mock_events_create(request):
1352
+ events_create_calls.append(request)
1353
+
1354
+ # WHEN we call the stream route and simulate a client disconnect
1355
+ flask_app = create_app()
1356
+ with flask_app.test_client() as test_client:
1357
+ with mock.patch("workflow_server.core.workflow_executor_context.create_vellum_client") as mock_create_client:
1358
+ mock_client = mock.MagicMock()
1359
+ mock_client.events.create = mock_events_create
1360
+ mock_create_client.return_value = mock_client
1361
+
1362
+ response = test_client.post("/workflow/stream", json=request_body)
1363
+
1364
+ # Get the response iterator and consume a few chunks to start the stream
1365
+ response_iter = response.response
1366
+ next(response_iter)
1367
+
1368
+ # Close the response to trigger GeneratorExit
1369
+ response_iter.close()
1370
+
1371
+ # THEN the events.create API should have been called with rejected event
1372
+ assert len(events_create_calls) > 0, "events.create should have been called on client disconnect"
1373
+
1374
+ # AND the call should include a workflow.execution.rejected event (sent as SDK event model)
1375
+ last_call = events_create_calls[-1]
1376
+ assert isinstance(last_call, list), "events.create should be called with a list"
1377
+ assert len(last_call) == 1, "Should have exactly one rejected event"
1378
+
1379
+ rejected_event = last_call[0]
1380
+ assert rejected_event.name == "workflow.execution.rejected", "Should be a rejected event"
1381
+
1382
+ # AND the rejected event should have the correct error message
1383
+ assert "client disconnected" in rejected_event.body.error.message.lower()
1384
+
1385
+ # AND the rejected event should have a workflow_definition
1386
+ # TODO: In the future, we should capture the real workflow_definition from the initiated event.
1387
+ # For now, we use BaseWorkflow as a placeholder.
1388
+ assert rejected_event.body.workflow_definition is not None, "Should have a workflow_definition"
@@ -15,12 +15,21 @@ from uuid import uuid4
15
15
  from typing import Any, Dict, Generator, Iterator, Optional, Union, cast
16
16
 
17
17
  from flask import Blueprint, Response, current_app as app, request, stream_with_context
18
+ import orjson
18
19
  from pydantic import ValidationError
19
20
  from vellum_ee.workflows.display.nodes.get_node_display_class import get_node_display_class
20
21
  from vellum_ee.workflows.display.types import WorkflowDisplayContext
21
22
  from vellum_ee.workflows.display.workflows import BaseWorkflowDisplay
22
23
  from vellum_ee.workflows.server.virtual_file_loader import VirtualFileFinder
23
24
 
25
+ from vellum.workflows import BaseWorkflow
26
+ from vellum.workflows.errors import WorkflowError, WorkflowErrorCode
27
+ from vellum.workflows.events.workflow import (
28
+ WorkflowExecutionInitiatedBody,
29
+ WorkflowExecutionInitiatedEvent,
30
+ WorkflowExecutionRejectedBody,
31
+ WorkflowExecutionRejectedEvent,
32
+ )
24
33
  from vellum.workflows.exceptions import WorkflowInitializationException
25
34
  from vellum.workflows.nodes import BaseNode
26
35
  from vellum.workflows.vellum_client import create_vellum_client
@@ -115,7 +124,7 @@ def stream_workflow_route() -> Response:
115
124
  for row in workflow_events:
116
125
  yield "\n"
117
126
  if isinstance(row, dict):
118
- dump = json.dumps(row)
127
+ dump = orjson.dumps(row).decode("utf-8")
119
128
  yield dump
120
129
  else:
121
130
  yield row
@@ -134,7 +143,7 @@ def stream_workflow_route() -> Response:
134
143
  # These can happen either from Vembda disconnects (possibily from predict disconnects) or
135
144
  # from knative activator gateway timeouts which are caused by idleTimeout or responseStartSeconds
136
145
  # being exceeded.
137
- app.logger.error(
146
+ app.logger.warning(
138
147
  "Client disconnected in the middle of the Workflow Stream",
139
148
  extra={
140
149
  "sentry_tags": {
@@ -143,6 +152,11 @@ def stream_workflow_route() -> Response:
143
152
  }
144
153
  },
145
154
  )
155
+ _emit_client_disconnect_events(
156
+ context,
157
+ span_id,
158
+ "Client disconnected in the middle of the Workflow Stream",
159
+ )
146
160
  return
147
161
  except Exception as e:
148
162
  logger.exception("Error during workflow response stream generator", extra={"error": e})
@@ -173,6 +187,75 @@ def stream_workflow_route() -> Response:
173
187
  return resp
174
188
 
175
189
 
190
+ def _emit_async_error_events(
191
+ context: WorkflowExecutorContext, error_message: str, stacktrace: Optional[str] = None
192
+ ) -> None:
193
+ """
194
+ Emit workflow execution error events when async execution fails before or during workflow startup.
195
+
196
+ This ensures that errors in async mode are properly reported to Vellum's events API,
197
+ making them visible in the executions UI.
198
+ """
199
+ try:
200
+ workflow_span_id = context.workflow_span_id or str(uuid4())
201
+
202
+ initiated_event = WorkflowExecutionInitiatedEvent[Any, Any](
203
+ trace_id=context.trace_id,
204
+ span_id=workflow_span_id,
205
+ body=WorkflowExecutionInitiatedBody(inputs=context.inputs),
206
+ parent=context.execution_context.parent_context if context.execution_context else None,
207
+ )
208
+
209
+ rejected_event = WorkflowExecutionRejectedEvent(
210
+ trace_id=context.trace_id,
211
+ span_id=workflow_span_id,
212
+ body=WorkflowExecutionRejectedBody(
213
+ error=WorkflowError(
214
+ message=error_message,
215
+ code=WorkflowErrorCode.INTERNAL_ERROR,
216
+ ),
217
+ stacktrace=stacktrace,
218
+ ),
219
+ parent=context.execution_context.parent_context if context.execution_context else None,
220
+ )
221
+
222
+ context.vellum_client.events.create(request=[initiated_event, rejected_event]) # type: ignore[list-item]
223
+ except Exception as e:
224
+ logger.exception(f"Failed to emit async error events: {e}")
225
+
226
+
227
+ def _emit_client_disconnect_events(
228
+ context: WorkflowExecutorContext,
229
+ workflow_span_id: str,
230
+ error_message: str,
231
+ ) -> None:
232
+ """
233
+ Emit workflow execution rejected event when a client disconnects mid-stream.
234
+
235
+ Since the workflow has already started streaming (the initiated event was already emitted),
236
+ we only need to emit the rejected event to properly close out the execution.
237
+ """
238
+ try:
239
+ # TODO: In the future, we should capture the real workflow_definition from the initiated event
240
+ # For now, we use BaseWorkflow as a placeholder
241
+ rejected_event = WorkflowExecutionRejectedEvent(
242
+ trace_id=context.trace_id,
243
+ span_id=workflow_span_id,
244
+ body=WorkflowExecutionRejectedBody(
245
+ workflow_definition=BaseWorkflow,
246
+ error=WorkflowError(
247
+ message=error_message,
248
+ code=WorkflowErrorCode.WORKFLOW_CANCELLED,
249
+ ),
250
+ ),
251
+ parent=context.execution_context.parent_context if context.execution_context else None,
252
+ )
253
+
254
+ context.vellum_client.events.create(request=[rejected_event]) # type: ignore[list-item]
255
+ except Exception as e:
256
+ logger.exception(f"Failed to emit client disconnect events: {e}")
257
+
258
+
176
259
  @bp.route("/async-exec", methods=["POST"])
177
260
  def async_exec_workflow() -> Response:
178
261
  data = request.get_json()
@@ -207,8 +290,8 @@ def async_exec_workflow() -> Response:
207
290
  try:
208
291
  start_workflow_result = _start_workflow(context)
209
292
  if isinstance(start_workflow_result, Response):
210
- # TODO same here, should return this response as en event or it will get yeeted to the nether
211
- # return start_workflow_result
293
+ error_detail = start_workflow_result.get_json().get("detail", "Unknown error during workflow startup")
294
+ _emit_async_error_events(context, error_detail)
212
295
  return
213
296
 
214
297
  workflow_events, vembda_initiated_event, process, span_id, headers = start_workflow_result
@@ -222,6 +305,7 @@ def async_exec_workflow() -> Response:
222
305
  )
223
306
  except Exception as e:
224
307
  logger.exception("Error during workflow async background worker", e)
308
+ _emit_async_error_events(context, str(e), traceback.format_exc())
225
309
  finally:
226
310
  if ENABLE_PROCESS_WRAPPER:
227
311
  try:
@@ -500,11 +584,11 @@ def stream_node_route() -> Response:
500
584
  break
501
585
 
502
586
  def generator() -> Generator[str, None, None]:
503
- yield json.dumps(vembda_initiated_event.model_dump(mode="json"))
587
+ yield orjson.dumps(vembda_initiated_event.model_dump(mode="json")).decode("utf-8")
504
588
 
505
589
  for row in node_events():
506
590
  yield "\n"
507
- yield json.dumps(row)
591
+ yield orjson.dumps(row).decode("utf-8")
508
592
 
509
593
  headers = {
510
594
  "X-Vellum-SDK-Version": vembda_initiated_event.body.sdk_version,
@@ -530,11 +614,18 @@ def serialize_route() -> Response:
530
614
  is_new_server = data.get("is_new_server", False)
531
615
  module = data.get("module")
532
616
 
617
+ headers = {
618
+ "X-Vellum-Is-New-Server": str(is_new_server).lower(),
619
+ }
620
+
533
621
  if not files:
622
+ error_message = "No files received"
623
+ logger.warning(error_message)
534
624
  return Response(
535
- json.dumps({"detail": "No files received"}),
625
+ json.dumps({"detail": error_message}),
536
626
  status=400,
537
627
  content_type="application/json",
628
+ headers=headers,
538
629
  )
539
630
 
540
631
  client = create_vellum_client(api_key=workspace_api_key)
@@ -543,10 +634,6 @@ def serialize_route() -> Response:
543
634
  namespace = get_random_namespace()
544
635
  virtual_finder = VirtualFileFinder(files, namespace, source_module=module)
545
636
 
546
- headers = {
547
- "X-Vellum-Is-New-Server": str(is_new_server).lower(),
548
- }
549
-
550
637
  try:
551
638
  sys.meta_path.append(virtual_finder)
552
639
  result = BaseWorkflowDisplay.serialize_module(namespace, client=client, dry_run=True)
@@ -1,11 +1,12 @@
1
1
  from datetime import datetime
2
- import json
3
2
  import logging
4
3
  import os
5
4
  from threading import Event as ThreadingEvent
6
5
  from uuid import uuid4
7
6
  from typing import Optional
8
7
 
8
+ import orjson
9
+
9
10
  from workflow_server.core.events import VembdaExecutionInitiatedBody, VembdaExecutionInitiatedEvent
10
11
  from workflow_server.core.executor import stream_workflow
11
12
  from workflow_server.core.utils import serialize_vembda_rejected_event
@@ -29,7 +30,7 @@ def run_code_exec_stream() -> None:
29
30
  split_input = input_raw.split("\n--vellum-input-stop--\n")
30
31
  input_json = split_input[0]
31
32
 
32
- input_data = json.loads(input_json)
33
+ input_data = orjson.loads(input_json)
33
34
  context = WorkflowExecutorContext.model_validate(input_data)
34
35
 
35
36
  print("--vellum-output-start--") # noqa: T201
@@ -53,7 +54,7 @@ def run_code_exec_stream() -> None:
53
54
  cancel_signal=ThreadingEvent(),
54
55
  )
55
56
  for line in stream_iterator:
56
- print(f"{_EVENT_LINE}{json.dumps(line)}") # noqa: T201
57
+ print(f"{_EVENT_LINE}{orjson.dumps(line).decode('utf-8')}") # noqa: T201
57
58
  except Exception as e:
58
59
  logger.exception(e)
59
60
 
@@ -14,14 +14,18 @@ logger = logging.getLogger(__name__)
14
14
 
15
15
 
16
16
  def get_is_workflow_cancelled(execution_id: UUID, vembda_public_url: Optional[str]) -> bool:
17
- response = requests.get(
18
- f"{vembda_public_url}/vembda-public/cancel-workflow-execution-status/{execution_id}",
19
- headers={"Accept": "application/json"},
20
- timeout=5,
21
- )
22
- response.raise_for_status()
17
+ try:
18
+ response = requests.get(
19
+ f"{vembda_public_url}/vembda-public/cancel-workflow-execution-status/{execution_id}",
20
+ headers={"Accept": "application/json"},
21
+ timeout=5,
22
+ )
23
+ response.raise_for_status()
23
24
 
24
- return response.json().get("cancelled")
25
+ return response.json().get("cancelled", False)
26
+ except Exception:
27
+ logger.exception("Error checking workflow cancellation status")
28
+ return False
25
29
 
26
30
 
27
31
  class CancelWorkflowWatcherThread(Thread):
@@ -1,6 +1,5 @@
1
1
  from datetime import datetime, timezone
2
2
  from io import StringIO
3
- import json
4
3
  import logging
5
4
  from multiprocessing import Process, Queue
6
5
  import os
@@ -11,9 +10,11 @@ from threading import Event as ThreadingEvent
11
10
  import time
12
11
  from traceback import format_exc
13
12
  from uuid import UUID, uuid4
14
- from typing import Any, Callable, Generator, Iterator, Optional, Tuple, Type
13
+ from typing import Any, Callable, Generator, Iterator, Optional, Tuple
15
14
 
15
+ import orjson
16
16
  from vellum_ee.workflows.display.utils.events import event_enricher
17
+ from vellum_ee.workflows.display.utils.expressions import base_descriptor_validator
17
18
  from vellum_ee.workflows.server.virtual_file_loader import VirtualFileFinder
18
19
 
19
20
  from vellum.workflows import BaseWorkflow
@@ -104,7 +105,7 @@ def _stream_workflow_wrapper(
104
105
  span_id_emitted = True
105
106
 
106
107
  for event in stream_iterator:
107
- queue.put(json.dumps(event))
108
+ queue.put(orjson.dumps(event).decode("utf-8"))
108
109
 
109
110
  except Exception as e:
110
111
  if not span_id_emitted:
@@ -177,6 +178,7 @@ def stream_workflow(
177
178
  node_output_mocks = MockNodeExecution.validate_all(
178
179
  executor_context.node_output_mocks,
179
180
  workflow.__class__,
181
+ descriptor_validator=base_descriptor_validator,
180
182
  )
181
183
 
182
184
  cancel_signal = cancel_signal or ThreadingEvent()
@@ -192,6 +194,7 @@ def stream_workflow(
192
194
  timeout=executor_context.timeout,
193
195
  trigger=trigger,
194
196
  execution_id=executor_context.workflow_span_id,
197
+ event_max_size=executor_context.event_max_size,
195
198
  )
196
199
  except WorkflowInitializationException as e:
197
200
  cancel_watcher_kill_switch.set()
@@ -273,32 +276,11 @@ def stream_node(
273
276
  disable_redirect: bool = True,
274
277
  ) -> Iterator[dict]:
275
278
  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
279
 
298
280
  def call_node() -> Generator[dict[str, Any], Any, None]:
299
281
  executor_context.stream_start_time = time.time_ns()
300
282
 
301
- for event in workflow.run_node(Node, inputs=executor_context.inputs): # type: ignore[arg-type]
283
+ for event in workflow.run_node(executor_context.node_ref, inputs=executor_context.inputs):
302
284
  yield event.model_dump(mode="json")
303
285
 
304
286
  return _call_stream(
@@ -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
@@ -41,6 +41,7 @@ class BaseExecutorContext(UniversalBaseModel):
41
41
  # when running in async mode.
42
42
  workflow_span_id: Optional[UUID] = None
43
43
  vembda_service_initiated_timestamp: Optional[int] = None
44
+ event_max_size: Optional[int] = None
44
45
 
45
46
  @field_validator("inputs", mode="before")
46
47
  @classmethod
@@ -91,6 +92,18 @@ class NodeExecutorContext(BaseExecutorContext):
91
92
  node_module: Optional[str] = None
92
93
  node_name: Optional[str] = None
93
94
 
95
+ @property
96
+ def node_ref(self) -> Union[UUID, str]:
97
+ """
98
+ Returns the node reference for use with workflow.run_node().
99
+
100
+ Returns node_id if it exists, otherwise returns the combination
101
+ of node_module and node_name as a fully qualified string.
102
+ """
103
+ if self.node_id:
104
+ return self.node_id
105
+ return f"{self.node_module}.{self.node_name}"
106
+
94
107
  @model_validator(mode="after")
95
108
  def validate_node_identification(self) -> Self:
96
109
  if not self.node_id and not (self.node_module and self.node_name):
@@ -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,
@@ -59,10 +59,19 @@ def convert_json_inputs_to_vellum(inputs: List[dict]) -> dict:
59
59
 
60
60
 
61
61
  def get_version() -> dict:
62
+ # Return hotswappable lock file so we can save it and reuse it
63
+ lock_file = None
64
+ try:
65
+ with open("/app/uv.lock", "r") as f:
66
+ lock_file = f.read()
67
+ except Exception:
68
+ pass
69
+
62
70
  return {
63
71
  "sdk_version": version("vellum-ai"),
64
72
  "server_version": "local" if is_development() else version("vellum-workflow-server"),
65
73
  "container_image": CONTAINER_IMAGE,
74
+ "lock_file": lock_file,
66
75
  }
67
76
 
68
77