vellum-workflow-server 1.8.2__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.
@@ -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
+ )
@@ -5,6 +5,7 @@ import io
5
5
  import json
6
6
  from queue import Empty
7
7
  import re
8
+ import time
8
9
  from unittest import mock
9
10
  from uuid import uuid4
10
11
 
@@ -133,6 +134,8 @@ class Workflow(BaseWorkflow):
133
134
 
134
135
  with mock.patch("builtins.open", mock.mock_open(read_data="104857600")):
135
136
  # WHEN we call the stream route
137
+ ts_ns = time.time_ns()
138
+ request_body["vembda_service_initiated_timestamp"] = ts_ns
136
139
  status_code, events = both_stream_types(request_body)
137
140
 
138
141
  # THEN we get a 200 response
@@ -174,6 +177,17 @@ class Workflow(BaseWorkflow):
174
177
  assert "sdk_version" in server_metadata
175
178
  assert "memory_usage_mb" in server_metadata
176
179
  assert isinstance(server_metadata["memory_usage_mb"], (int, float))
180
+ assert "is_new_server" in server_metadata
181
+ assert server_metadata["is_new_server"] is False
182
+
183
+ # AND the initiated event should have initiated_latency within a reasonable range
184
+ assert "initiated_latency" in server_metadata, "initiated_latency should be present in server_metadata"
185
+ initiated_latency = server_metadata["initiated_latency"]
186
+ assert isinstance(initiated_latency, int), "initiated_latency should be an integer (nanoseconds)"
187
+ # Latency should be positive and less than 60 seconds (60_000_000_000 nanoseconds) for CI
188
+ assert (
189
+ 0 < initiated_latency < 60_000_000_000
190
+ ), f"initiated_latency should be between 0 and 60 seconds, got {initiated_latency} ns"
177
191
 
178
192
  assert events[2]["name"] == "workflow.execution.fulfilled", events[2]
179
193
  assert events[2]["body"]["workflow_definition"]["module"] == ["test", "workflow"]
@@ -384,9 +398,15 @@ class State(BaseState):
384
398
  def test_stream_workflow_route__bad_indent_in_inputs_file(both_stream_types):
385
399
  # GIVEN a valid request body
386
400
  span_id = uuid4()
401
+ trace_id = uuid4()
402
+ parent_span_id = uuid4()
387
403
  request_body = {
388
404
  "timeout": 360,
389
405
  "execution_id": str(span_id),
406
+ "execution_context": {
407
+ "trace_id": str(trace_id),
408
+ "parent_context": {"span_id": str(parent_span_id)},
409
+ },
390
410
  "inputs": [
391
411
  {"name": "foo", "type": "STRING", "value": "hello"},
392
412
  ],
@@ -423,7 +443,7 @@ from vellum.workflows.inputs import BaseInputs
423
443
 
424
444
  assert events[0] == {
425
445
  "id": mock.ANY,
426
- "trace_id": mock.ANY,
446
+ "trace_id": str(trace_id),
427
447
  "span_id": str(span_id),
428
448
  "timestamp": mock.ANY,
429
449
  "api_version": "2024-10-25",
@@ -437,9 +457,19 @@ from vellum.workflows.inputs import BaseInputs
437
457
  }
438
458
 
439
459
  assert events[1]["name"] == "workflow.execution.initiated"
460
+ assert events[1]["trace_id"] == str(trace_id), "workflow initiated event should use request trace_id"
461
+ assert events[1]["parent"] is not None, "workflow initiated event should have parent context"
462
+ assert events[1]["parent"]["span_id"] == str(
463
+ parent_span_id
464
+ ), "workflow initiated event parent should match request parent_context"
440
465
 
441
466
  assert events[2]["name"] == "workflow.execution.rejected"
467
+ assert events[2]["trace_id"] == str(trace_id), "workflow rejected event should use request trace_id"
442
468
  assert events[2]["span_id"] == events[1]["span_id"]
469
+ assert events[2]["parent"] is not None, "workflow rejected event should have parent context"
470
+ assert events[2]["parent"]["span_id"] == str(
471
+ parent_span_id
472
+ ), "workflow rejected event parent should match request parent_context"
443
473
  assert (
444
474
  "Syntax Error raised while loading Workflow: "
445
475
  "unexpected indent (inputs.py, line 3)" in events[2]["body"]["error"]["message"]
@@ -1211,3 +1241,72 @@ class InvalidWorkflow(BaseWorkflow):
1211
1241
  assert events[3]["name"] == "vembda.execution.fulfilled"
1212
1242
  assert events[3]["span_id"] == str(span_id)
1213
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"