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.
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/PKG-INFO +7 -4
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/README.md +3 -1
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/pyproject.toml +4 -3
- {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
- vellum_workflow_server-1.12.0.post1/src/workflow_server/api/tests/test_workflow_view_async_exec.py +410 -0
- {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
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/api/workflow_view.py +98 -11
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/code_exec_runner.py +4 -3
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/core/cancel_workflow.py +11 -7
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/core/executor.py +7 -25
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/core/utils.py +4 -0
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/core/workflow_executor_context.py +14 -1
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/start.py +2 -2
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/utils/utils.py +9 -0
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/__init__.py +0 -0
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/api/__init__.py +0 -0
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/api/auth_middleware.py +0 -0
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/api/healthz_view.py +0 -0
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/api/status_view.py +0 -0
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/api/tests/__init__.py +0 -0
- {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
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/config.py +0 -0
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/core/__init__.py +0 -0
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/core/events.py +0 -0
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/logging_config.py +0 -0
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/server.py +0 -0
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/utils/__init__.py +0 -0
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/utils/exit_handler.py +0 -0
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/utils/log_proxy.py +0 -0
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/utils/oom_killer.py +0 -0
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/utils/sentry.py +0 -0
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/utils/system_utils.py +0 -0
- {vellum_workflow_server-1.9.7.post1 → vellum_workflow_server-1.12.0.post1}/src/workflow_server/utils/tests/__init__.py +0 -0
- {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
- {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
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
49
|
-
|
|
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": [
|
|
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",
|
vellum_workflow_server-1.12.0.post1/src/workflow_server/api/tests/test_workflow_view_async_exec.py
ADDED
|
@@ -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
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
211
|
-
|
|
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
|
|
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
|
|
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":
|
|
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 =
|
|
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}{
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|