vellum-workflow-server 1.5.6__tar.gz → 1.9.1.post2__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.5.6 → vellum_workflow_server-1.9.1.post2}/PKG-INFO +2 -2
  2. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/pyproject.toml +2 -2
  3. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/api/auth_middleware.py +2 -2
  4. vellum_workflow_server-1.9.1.post2/src/workflow_server/api/status_view.py +19 -0
  5. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/api/tests/test_workflow_view.py +96 -3
  6. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/api/tests/test_workflow_view_stream_workflow_route.py +203 -5
  7. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/api/workflow_view.py +241 -155
  8. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/code_exec_runner.py +2 -2
  9. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/config.py +8 -0
  10. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/core/cancel_workflow.py +7 -5
  11. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/core/executor.py +93 -130
  12. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/core/workflow_executor_context.py +34 -45
  13. vellum_workflow_server-1.9.1.post2/src/workflow_server/logging_config.py +39 -0
  14. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/server.py +22 -5
  15. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/start.py +17 -3
  16. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/utils/oom_killer.py +6 -2
  17. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/utils/sentry.py +21 -0
  18. vellum_workflow_server-1.9.1.post2/src/workflow_server/utils/tests/test_sentry_integration.py +143 -0
  19. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/utils/utils.py +2 -1
  20. vellum_workflow_server-1.5.6/src/workflow_server/utils/tests/test_sentry_integration.py +0 -69
  21. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/README.md +0 -0
  22. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/__init__.py +0 -0
  23. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/api/__init__.py +0 -0
  24. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/api/healthz_view.py +0 -0
  25. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/api/tests/__init__.py +0 -0
  26. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/api/tests/test_input_display_mapping.py +0 -0
  27. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/core/__init__.py +0 -0
  28. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/core/events.py +0 -0
  29. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/core/utils.py +0 -0
  30. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/utils/__init__.py +0 -0
  31. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/utils/exit_handler.py +0 -0
  32. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/utils/log_proxy.py +0 -0
  33. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/utils/system_utils.py +0 -0
  34. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/utils/tests/__init__.py +0 -0
  35. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/src/workflow_server/utils/tests/test_system_utils.py +0 -0
  36. {vellum_workflow_server-1.5.6 → vellum_workflow_server-1.9.1.post2}/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.5.6
3
+ Version: 1.9.1.post2
4
4
  Summary:
5
5
  License: AGPL
6
6
  Requires-Python: >=3.9.0,<4
@@ -29,7 +29,7 @@ Requires-Dist: pyjwt (==2.10.0)
29
29
  Requires-Dist: python-dotenv (==1.0.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.5.6)
32
+ Requires-Dist: vellum-ai (==1.9.1)
33
33
  Description-Content-Type: text/markdown
34
34
 
35
35
  # Vellum Workflow Runner Server
@@ -3,7 +3,7 @@ name = "vellum-workflow-server"
3
3
 
4
4
  [tool.poetry]
5
5
  name = "vellum-workflow-server"
6
- version = "1.5.6"
6
+ version = "1.9.1.post2"
7
7
  description = ""
8
8
  readme = "README.md"
9
9
  authors = []
@@ -45,7 +45,7 @@ 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.5.6"
48
+ vellum-ai = "1.9.1"
49
49
  python-dotenv = "1.0.1"
50
50
  retrying = "1.3.4"
51
51
  sentry-sdk = {extras = ["flask"], version = "2.20.0"}
@@ -5,7 +5,7 @@ from flask import Flask, Request, Response
5
5
  import jwt
6
6
  from jwt import ExpiredSignatureError
7
7
 
8
- from workflow_server.config import IS_VPC, NAMESPACE, VEMBDA_PUBLIC_KEY, is_development
8
+ from workflow_server.config import IS_ASYNC_MODE, IS_VPC, NAMESPACE, VEMBDA_PUBLIC_KEY, is_development
9
9
 
10
10
 
11
11
  class AuthMiddleware:
@@ -15,7 +15,7 @@ class AuthMiddleware:
15
15
  def __call__(self, environ: Dict[str, Any], start_response: Any) -> Any:
16
16
  try:
17
17
  request = Request(environ)
18
- if not request.path.startswith("/healthz") and not is_development() and not IS_VPC:
18
+ if not request.path.startswith("/healthz") and not is_development() and not IS_VPC and not IS_ASYNC_MODE:
19
19
  token = request.headers.get("X-Vembda-Signature")
20
20
  if token:
21
21
  decoded = jwt.decode(token, VEMBDA_PUBLIC_KEY, algorithms=["RS256"])
@@ -0,0 +1,19 @@
1
+ from typing import Tuple
2
+
3
+ from flask import Blueprint, Response, jsonify
4
+
5
+ from workflow_server.config import CONCURRENCY
6
+ from workflow_server.utils.system_utils import get_active_process_count
7
+
8
+ bp = Blueprint("status", __name__)
9
+
10
+
11
+ @bp.route("/is_available", methods=["GET"])
12
+ def is_available() -> Tuple[Response, int]:
13
+ resp = jsonify(
14
+ available=get_active_process_count() < CONCURRENCY,
15
+ process_count=get_active_process_count(),
16
+ max_concurrency=CONCURRENCY,
17
+ )
18
+
19
+ return resp, 200
@@ -373,12 +373,12 @@ class MyAdditionNode(BaseNode):
373
373
  {
374
374
  "id": "aed3bcbb-d243-4a77-bb5e-409e9a28e868",
375
375
  "name": "arg1",
376
- "value": {"type": "CONSTANT_VALUE", "value": {"type": "JSON", "value": None}},
376
+ "value": None,
377
377
  },
378
378
  {
379
379
  "id": "9225d225-a41b-4642-8964-f28f58dcf4bf",
380
380
  "name": "arg2",
381
- "value": {"type": "CONSTANT_VALUE", "value": {"type": "JSON", "value": None}},
381
+ "value": None,
382
382
  },
383
383
  ],
384
384
  "base": {"module": ["vellum", "workflows", "nodes", "bases", "base"], "name": "BaseNode"},
@@ -429,7 +429,7 @@ class BrokenNode(BaseNode) # Missing colon
429
429
  with flask_app.test_client() as test_client:
430
430
  response = test_client.post("/workflow/serialize", json={"files": {"broken_node.py": invalid_content}})
431
431
 
432
- # THEN we should get a 4xx response
432
+ # THEN we should get a 400 response
433
433
  assert response.status_code == 400
434
434
 
435
435
  # AND the response should contain an error message
@@ -537,6 +537,57 @@ def test_serialize_route__with_invalid_workspace_api_key():
537
537
  assert "exec_config" in response.json
538
538
 
539
539
 
540
+ def test_serialize_route__with_is_new_server_header():
541
+ """
542
+ Tests that the serialize route returns the is_new_server header.
543
+ """
544
+ # GIVEN a Flask application
545
+ flask_app = create_app()
546
+
547
+ workflow_files = {
548
+ "__init__.py": "",
549
+ "workflow.py": (
550
+ "from vellum.workflows import BaseWorkflow\n\n"
551
+ "class Workflow(BaseWorkflow):\n"
552
+ " class Outputs(BaseWorkflow.Outputs):\n"
553
+ " foo = 'hello'\n"
554
+ ),
555
+ }
556
+
557
+ # WHEN we make a request with is_new_server=True
558
+ with flask_app.test_client() as test_client:
559
+ response = test_client.post("/workflow/serialize", json={"files": workflow_files, "is_new_server": True})
560
+
561
+ # THEN we should get a successful response
562
+ assert response.status_code == 200
563
+
564
+ # AND the response should contain the is_new_server header set to true
565
+ assert "X-Vellum-Is-New-Server" in response.headers
566
+ assert response.headers["X-Vellum-Is-New-Server"] == "true"
567
+
568
+ # WHEN we make a request with is_new_server=False
569
+ with flask_app.test_client() as test_client:
570
+ response = test_client.post("/workflow/serialize", json={"files": workflow_files, "is_new_server": False})
571
+
572
+ # THEN we should get a successful response
573
+ assert response.status_code == 200
574
+
575
+ # AND the response should contain the is_new_server header set to false
576
+ assert "X-Vellum-Is-New-Server" in response.headers
577
+ assert response.headers["X-Vellum-Is-New-Server"] == "false"
578
+
579
+ # WHEN we make a request without is_new_server
580
+ with flask_app.test_client() as test_client:
581
+ response = test_client.post("/workflow/serialize", json={"files": workflow_files})
582
+
583
+ # THEN we should get a successful response
584
+ assert response.status_code == 200
585
+
586
+ # AND the response should contain the is_new_server header set to false (default)
587
+ assert "X-Vellum-Is-New-Server" in response.headers
588
+ assert response.headers["X-Vellum-Is-New-Server"] == "false"
589
+
590
+
540
591
  def test_stream_node_route__with_node_id():
541
592
  """
542
593
  Tests that the stream-node endpoint works with node_id.
@@ -729,3 +780,45 @@ class Workflow(BaseWorkflow):
729
780
  assert events[0]["name"] == "vembda.execution.initiated"
730
781
  assert events[1]["name"] == "vembda.execution.fulfilled"
731
782
  assert len(events) == 2
783
+
784
+
785
+ def test_serialize_route__with_invalid_nested_set_graph():
786
+ """
787
+ Tests that a workflow with an invalid nested set graph structure raises a clear user-facing exception.
788
+ """
789
+ # GIVEN a Flask application
790
+ flask_app = create_app()
791
+
792
+ invalid_workflow_content = """
793
+ from vellum.workflows import BaseWorkflow
794
+ from vellum.workflows.nodes import BaseNode
795
+
796
+ class TestNode(BaseNode):
797
+ class Outputs(BaseNode.Outputs):
798
+ value = "test"
799
+
800
+ class InvalidWorkflow(BaseWorkflow):
801
+ graph = {TestNode, {TestNode}}
802
+
803
+ class Outputs(BaseWorkflow.Outputs):
804
+ result = TestNode.Outputs.value
805
+ """
806
+
807
+ workflow_files = {
808
+ "__init__.py": "",
809
+ "workflow.py": invalid_workflow_content,
810
+ }
811
+
812
+ # WHEN we make a request to the serialize route
813
+ with flask_app.test_client() as test_client:
814
+ response = test_client.post("/workflow/serialize", json={"files": workflow_files})
815
+
816
+ # THEN we should get a 400 response
817
+ assert response.status_code == 400
818
+
819
+ # AND the response should contain a user-friendly error message
820
+ assert "detail" in response.json
821
+ error_detail = response.json["detail"]
822
+ assert "Serialization failed" in error_detail
823
+ assert "Invalid graph structure detected" in error_detail
824
+ assert "contact Vellum support" in error_detail
@@ -117,7 +117,7 @@ def test_stream_workflow_route__happy_path(both_stream_types):
117
117
  "execution_id": str(span_id),
118
118
  "inputs": [],
119
119
  "environment_api_key": "test",
120
- "module": "workflow",
120
+ "module": "test",
121
121
  "timeout": 360,
122
122
  "files": {
123
123
  "__init__.py": "",
@@ -131,8 +131,9 @@ class Workflow(BaseWorkflow):
131
131
  },
132
132
  }
133
133
 
134
- # WHEN we call the stream route
135
- status_code, events = both_stream_types(request_body)
134
+ with mock.patch("builtins.open", mock.mock_open(read_data="104857600")):
135
+ # WHEN we call the stream route
136
+ status_code, events = both_stream_types(request_body)
136
137
 
137
138
  # THEN we get a 200 response
138
139
  assert status_code == 200, events
@@ -154,8 +155,37 @@ class Workflow(BaseWorkflow):
154
155
  }
155
156
 
156
157
  assert events[1]["name"] == "workflow.execution.initiated", events[1]
158
+ assert events[1]["body"]["workflow_definition"]["module"] == ["test", "workflow"]
157
159
  assert "display_context" in events[1]["body"], events[1]["body"]
160
+ display_context = events[1]["body"]["display_context"]
161
+ assert "node_displays" in display_context
162
+ assert "workflow_inputs" in display_context
163
+ assert "workflow_outputs" in display_context
164
+ assert isinstance(display_context["node_displays"], dict)
165
+ assert isinstance(display_context["workflow_inputs"], dict)
166
+ assert isinstance(display_context["workflow_outputs"], dict)
167
+ assert "foo" in display_context["workflow_outputs"]
168
+
169
+ # AND the initiated event should have server_metadata with version info and memory usage
170
+ assert "server_metadata" in events[1]["body"], events[1]["body"]
171
+ server_metadata = events[1]["body"]["server_metadata"]
172
+ assert server_metadata is not None, "server_metadata should not be None"
173
+ assert "server_version" in server_metadata
174
+ assert "sdk_version" in server_metadata
175
+ assert "memory_usage_mb" in server_metadata
176
+ assert isinstance(server_metadata["memory_usage_mb"], (int, float))
177
+ assert "is_new_server" in server_metadata
178
+ assert server_metadata["is_new_server"] is False
179
+
158
180
  assert events[2]["name"] == "workflow.execution.fulfilled", events[2]
181
+ assert events[2]["body"]["workflow_definition"]["module"] == ["test", "workflow"]
182
+
183
+ # AND the fulfilled event should have server_metadata with memory usage
184
+ assert "server_metadata" in events[2]["body"], events[2]["body"]
185
+ fulfilled_metadata = events[2]["body"]["server_metadata"]
186
+ assert fulfilled_metadata is not None, "fulfilled server_metadata should not be None"
187
+ assert "memory_usage_mb" in fulfilled_metadata
188
+ assert isinstance(fulfilled_metadata["memory_usage_mb"], (int, float))
159
189
 
160
190
  assert events[3] == {
161
191
  "id": mock.ANY,
@@ -234,6 +264,15 @@ class Inputs(BaseInputs):
234
264
 
235
265
  assert events[1]["name"] == "workflow.execution.initiated", events[1]
236
266
  assert "display_context" in events[1]["body"], events[1]["body"]
267
+ display_context = events[1]["body"]["display_context"]
268
+ assert "node_displays" in display_context
269
+ assert "workflow_inputs" in display_context
270
+ assert "workflow_outputs" in display_context
271
+ assert isinstance(display_context["node_displays"], dict)
272
+ assert isinstance(display_context["workflow_inputs"], dict)
273
+ assert isinstance(display_context["workflow_outputs"], dict)
274
+ assert "foo" in display_context["workflow_inputs"]
275
+ assert "foo" in display_context["workflow_outputs"]
237
276
  assert events[2]["name"] == "workflow.execution.fulfilled", events[2]
238
277
 
239
278
  assert events[3] == {
@@ -311,6 +350,14 @@ class State(BaseState):
311
350
 
312
351
  assert events[1]["name"] == "workflow.execution.initiated", events[1]
313
352
  assert "display_context" in events[1]["body"], events[1]["body"]
353
+ display_context = events[1]["body"]["display_context"]
354
+ assert "node_displays" in display_context
355
+ assert "workflow_inputs" in display_context
356
+ assert "workflow_outputs" in display_context
357
+ assert isinstance(display_context["node_displays"], dict)
358
+ assert isinstance(display_context["workflow_inputs"], dict)
359
+ assert isinstance(display_context["workflow_outputs"], dict)
360
+ assert "foo" in display_context["workflow_outputs"]
314
361
  assert events[2]["name"] == "workflow.execution.fulfilled", events[2]
315
362
  assert events[2]["body"]["outputs"] == {"foo": "bar"}
316
363
 
@@ -339,9 +386,15 @@ class State(BaseState):
339
386
  def test_stream_workflow_route__bad_indent_in_inputs_file(both_stream_types):
340
387
  # GIVEN a valid request body
341
388
  span_id = uuid4()
389
+ trace_id = uuid4()
390
+ parent_span_id = uuid4()
342
391
  request_body = {
343
392
  "timeout": 360,
344
393
  "execution_id": str(span_id),
394
+ "execution_context": {
395
+ "trace_id": str(trace_id),
396
+ "parent_context": {"span_id": str(parent_span_id)},
397
+ },
345
398
  "inputs": [
346
399
  {"name": "foo", "type": "STRING", "value": "hello"},
347
400
  ],
@@ -378,7 +431,7 @@ from vellum.workflows.inputs import BaseInputs
378
431
 
379
432
  assert events[0] == {
380
433
  "id": mock.ANY,
381
- "trace_id": mock.ANY,
434
+ "trace_id": str(trace_id),
382
435
  "span_id": str(span_id),
383
436
  "timestamp": mock.ANY,
384
437
  "api_version": "2024-10-25",
@@ -392,11 +445,22 @@ from vellum.workflows.inputs import BaseInputs
392
445
  }
393
446
 
394
447
  assert events[1]["name"] == "workflow.execution.initiated"
448
+ assert events[1]["trace_id"] == str(trace_id), "workflow initiated event should use request trace_id"
449
+ assert events[1]["parent"] is not None, "workflow initiated event should have parent context"
450
+ assert events[1]["parent"]["span_id"] == str(
451
+ parent_span_id
452
+ ), "workflow initiated event parent should match request parent_context"
395
453
 
396
454
  assert events[2]["name"] == "workflow.execution.rejected"
455
+ assert events[2]["trace_id"] == str(trace_id), "workflow rejected event should use request trace_id"
397
456
  assert events[2]["span_id"] == events[1]["span_id"]
457
+ assert events[2]["parent"] is not None, "workflow rejected event should have parent context"
458
+ assert events[2]["parent"]["span_id"] == str(
459
+ parent_span_id
460
+ ), "workflow rejected event parent should match request parent_context"
398
461
  assert (
399
- "Failed to initialize workflow: unexpected indent (inputs.py, line 3)" in events[2]["body"]["error"]["message"]
462
+ "Syntax Error raised while loading Workflow: "
463
+ "unexpected indent (inputs.py, line 3)" in events[2]["body"]["error"]["message"]
400
464
  )
401
465
 
402
466
  assert events[3] == {
@@ -570,6 +634,71 @@ class BasicCancellableWorkflow(BaseWorkflow):
570
634
  )
571
635
 
572
636
 
637
+ def test_stream_workflow_route__timeout_emits_rejection_events():
638
+ """
639
+ Tests that when a workflow times out, we emit node and workflow rejection events.
640
+ """
641
+
642
+ span_id = uuid4()
643
+ request_body = {
644
+ "timeout": 1,
645
+ "execution_id": str(span_id),
646
+ "inputs": [],
647
+ "environment_api_key": "test",
648
+ "module": "workflow",
649
+ "files": {
650
+ "__init__.py": "",
651
+ "workflow.py": """\
652
+ import time
653
+
654
+ from vellum.workflows.nodes.bases.base import BaseNode
655
+ from vellum.workflows.workflows.base import BaseWorkflow
656
+
657
+
658
+ class LongRunningNode(BaseNode):
659
+ class Outputs(BaseNode.Outputs):
660
+ value: str
661
+
662
+ def run(self) -> Outputs:
663
+ time.sleep(30)
664
+ return self.Outputs(value="hello world")
665
+
666
+
667
+ class TimeoutWorkflow(BaseWorkflow):
668
+ graph = LongRunningNode
669
+ class Outputs(BaseWorkflow.Outputs):
670
+ final_value = LongRunningNode.Outputs.value
671
+
672
+ """,
673
+ },
674
+ }
675
+
676
+ status_code, events = flask_stream(request_body)
677
+
678
+ assert status_code == 200
679
+
680
+ event_names = [e["name"] for e in events]
681
+
682
+ assert "vembda.execution.initiated" in event_names
683
+ assert "workflow.execution.initiated" in event_names
684
+ assert "node.execution.initiated" in event_names
685
+
686
+ assert "node.execution.rejected" in event_names, "Should emit node.execution.rejected on timeout"
687
+ node_execution_rejected = next(e for e in events if e["name"] == "node.execution.rejected")
688
+ assert "vellum/workflows/runner/runner.py" in node_execution_rejected["body"]["stacktrace"]
689
+
690
+ assert "workflow.execution.rejected" in event_names, "Should emit workflow.execution.rejected on timeout"
691
+ workflow_execution_rejected = next(e for e in events if e["name"] == "workflow.execution.rejected")
692
+ assert workflow_execution_rejected["body"]["error"]["code"] == "WORKFLOW_TIMEOUT"
693
+ # TODO: Uncomment once version 1.8.1 is released
694
+ # assert "stacktrace" in workflow_execution_rejected["body"]
695
+ # assert "vellum/workflows/runner/runner.py" in workflow_execution_rejected["body"]["stacktrace"]
696
+
697
+ assert "vembda.execution.fulfilled" in event_names
698
+ vembda_fulfilled = next(e for e in events if e["name"] == "vembda.execution.fulfilled")
699
+ assert vembda_fulfilled["body"]["timed_out"] is True
700
+
701
+
573
702
  def test_stream_workflow_route__very_large_events(both_stream_types):
574
703
  # GIVEN a valid request body
575
704
  span_id = uuid4()
@@ -1031,3 +1160,72 @@ class Workflow(BaseWorkflow):
1031
1160
  assert len(event_names) == 2, "Should include 2 events"
1032
1161
  assert "workflow.execution.initiated" in event_names, "Should include workflow.execution.initiated event"
1033
1162
  assert "workflow.execution.fulfilled" in event_names, "Should include workflow.execution.fulfilled event"
1163
+
1164
+
1165
+ def test_stream_workflow_route__with_invalid_nested_set_graph(both_stream_types):
1166
+ """
1167
+ Tests that a workflow with an invalid nested set graph structure raises a clear error in the stream response.
1168
+ """
1169
+ # GIVEN a Flask application and invalid workflow content with nested set graph
1170
+ span_id = uuid4()
1171
+
1172
+ invalid_workflow_content = """
1173
+ from vellum.workflows import BaseWorkflow
1174
+ from vellum.workflows.nodes import BaseNode
1175
+
1176
+ class TestNode(BaseNode):
1177
+ class Outputs(BaseNode.Outputs):
1178
+ value = "test"
1179
+
1180
+ class InvalidWorkflow(BaseWorkflow):
1181
+ graph = {TestNode, {TestNode}}
1182
+
1183
+ class Outputs(BaseWorkflow.Outputs):
1184
+ result = TestNode.Outputs.value
1185
+ """
1186
+
1187
+ request_body = {
1188
+ "timeout": 360,
1189
+ "execution_id": str(span_id),
1190
+ "inputs": [],
1191
+ "environment_api_key": "test",
1192
+ "module": "workflow",
1193
+ "files": {
1194
+ "__init__.py": "",
1195
+ "workflow.py": invalid_workflow_content,
1196
+ },
1197
+ }
1198
+
1199
+ # WHEN we call the stream route
1200
+ status_code, events = both_stream_types(request_body)
1201
+
1202
+ # THEN we get a 200 response
1203
+ assert status_code == 200, events
1204
+
1205
+ # THEN we get the expected events: vembda initiated, workflow initiated, workflow rejected, vembda fulfilled
1206
+ assert len(events) == 4
1207
+
1208
+ # AND the first event should be vembda execution initiated
1209
+ assert events[0]["name"] == "vembda.execution.initiated"
1210
+ assert events[0]["span_id"] == str(span_id)
1211
+
1212
+ # AND the second event should be workflow execution initiated
1213
+ assert events[1]["name"] == "workflow.execution.initiated"
1214
+
1215
+ # AND the third event should be workflow execution rejected
1216
+ assert events[2]["name"] == "workflow.execution.rejected"
1217
+ assert events[1]["span_id"] == events[2]["span_id"]
1218
+
1219
+ # AND the error message should contain information about the invalid graph structure
1220
+ error_message = events[2]["body"]["error"]["message"]
1221
+ expected_message = (
1222
+ "Invalid graph structure detected. "
1223
+ "Nested sets or unsupported graph types are not allowed. "
1224
+ "Please contact Vellum support for assistance with Workflow configuration."
1225
+ )
1226
+ assert error_message == expected_message
1227
+
1228
+ # AND the fourth event should be vembda execution fulfilled
1229
+ assert events[3]["name"] == "vembda.execution.fulfilled"
1230
+ assert events[3]["span_id"] == str(span_id)
1231
+ assert events[3]["body"]["exit_code"] == 0