service-forge 0.1.18__py3-none-any.whl → 0.1.39__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.
Potentially problematic release.
This version of service-forge might be problematic. Click here for more details.
- service_forge/__init__.py +0 -0
- service_forge/api/deprecated_websocket_api.py +91 -33
- service_forge/api/deprecated_websocket_manager.py +70 -53
- service_forge/api/http_api.py +205 -55
- service_forge/api/kafka_api.py +113 -25
- service_forge/api/routers/meta_api/meta_api_router.py +57 -0
- service_forge/api/routers/service/service_router.py +42 -6
- service_forge/api/routers/trace/trace_router.py +326 -0
- service_forge/api/routers/websocket/websocket_router.py +69 -1
- service_forge/api/service_studio.py +9 -0
- service_forge/db/database.py +17 -0
- service_forge/execution_context.py +106 -0
- service_forge/frontend/static/assets/CreateNewNodeDialog-DkrEMxSH.js +1 -0
- service_forge/frontend/static/assets/CreateNewNodeDialog-DwFcBiGp.css +1 -0
- service_forge/frontend/static/assets/EditorSidePanel-BNVms9Fq.css +1 -0
- service_forge/frontend/static/assets/EditorSidePanel-DZbB3ILL.js +1 -0
- service_forge/frontend/static/assets/FeedbackPanel-CC8HX7Yo.js +1 -0
- service_forge/frontend/static/assets/FeedbackPanel-ClgniIVk.css +1 -0
- service_forge/frontend/static/assets/FormattedCodeViewer.vue_vue_type_script_setup_true_lang-BNuI1NCs.js +1 -0
- service_forge/frontend/static/assets/NodeDetailWrapper-BqFFM7-r.js +1 -0
- service_forge/frontend/static/assets/NodeDetailWrapper-pZBxv3J0.css +1 -0
- service_forge/frontend/static/assets/TestRunningDialog-D0GrCoYs.js +1 -0
- service_forge/frontend/static/assets/TestRunningDialog-dhXOsPgH.css +1 -0
- service_forge/frontend/static/assets/TracePanelWrapper-B9zvDSc_.js +1 -0
- service_forge/frontend/static/assets/TracePanelWrapper-BiednCrq.css +1 -0
- service_forge/frontend/static/assets/WorkflowEditor-CcaGGbko.js +3 -0
- service_forge/frontend/static/assets/WorkflowEditor-CmasOOYK.css +1 -0
- service_forge/frontend/static/assets/WorkflowList-Copuwi-a.css +1 -0
- service_forge/frontend/static/assets/WorkflowList-LrRJ7B7h.js +1 -0
- service_forge/frontend/static/assets/WorkflowStudio-CthjgII2.css +1 -0
- service_forge/frontend/static/assets/WorkflowStudio-FCyhGD4y.js +2 -0
- service_forge/frontend/static/assets/api-BDer3rj7.css +1 -0
- service_forge/frontend/static/assets/api-DyiqpKJK.js +1 -0
- service_forge/frontend/static/assets/code-editor-DBSql_sc.js +12 -0
- service_forge/frontend/static/assets/el-collapse-item-D4LG0FJ0.css +1 -0
- service_forge/frontend/static/assets/el-empty-D4ZqTl4F.css +1 -0
- service_forge/frontend/static/assets/el-form-item-BWkJzdQ_.css +1 -0
- service_forge/frontend/static/assets/el-input-D6B3r8CH.css +1 -0
- service_forge/frontend/static/assets/el-select-B0XIb2QK.css +1 -0
- service_forge/frontend/static/assets/el-tag-DljBBxJR.css +1 -0
- service_forge/frontend/static/assets/element-ui-D3x2y3TA.js +12 -0
- service_forge/frontend/static/assets/elkjs-Dm5QV7uy.js +24 -0
- service_forge/frontend/static/assets/highlightjs-D4ATuRwX.js +3 -0
- service_forge/frontend/static/assets/index-BMvodlwc.js +2 -0
- service_forge/frontend/static/assets/index-CjSe8i2q.css +1 -0
- service_forge/frontend/static/assets/js-yaml-yTPt38rv.js +32 -0
- service_forge/frontend/static/assets/time-DKCKV6Ug.js +1 -0
- service_forge/frontend/static/assets/ui-components-DQ7-U3pr.js +1 -0
- service_forge/frontend/static/assets/vue-core-DL-LgTX0.js +1 -0
- service_forge/frontend/static/assets/vue-flow-Dn7R8GPr.js +39 -0
- service_forge/frontend/static/index.html +16 -0
- service_forge/frontend/static/vite.svg +1 -0
- service_forge/model/meta_api/__init__.py +0 -0
- service_forge/model/meta_api/schema.py +29 -0
- service_forge/model/trace.py +82 -0
- service_forge/service.py +39 -11
- service_forge/service_config.py +14 -0
- service_forge/sft/cli.py +39 -0
- service_forge/sft/cmd/remote_deploy.py +160 -0
- service_forge/sft/cmd/remote_list_tars.py +111 -0
- service_forge/sft/config/injector.py +54 -7
- service_forge/sft/config/injector_default_files.py +13 -1
- service_forge/sft/config/sf_metadata.py +31 -27
- service_forge/sft/config/sft_config.py +18 -0
- service_forge/sft/util/assert_util.py +0 -1
- service_forge/telemetry.py +66 -0
- service_forge/utils/default_type_converter.py +1 -1
- service_forge/utils/type_converter.py +5 -0
- service_forge/utils/workflow_clone.py +1 -0
- service_forge/workflow/node.py +274 -27
- service_forge/workflow/triggers/fast_api_trigger.py +64 -28
- service_forge/workflow/triggers/websocket_api_trigger.py +66 -38
- service_forge/workflow/workflow.py +140 -37
- service_forge/workflow/workflow_callback.py +27 -4
- service_forge/workflow/workflow_factory.py +14 -0
- {service_forge-0.1.18.dist-info → service_forge-0.1.39.dist-info}/METADATA +4 -1
- service_forge-0.1.39.dist-info/RECORD +134 -0
- service_forge-0.1.18.dist-info/RECORD +0 -83
- {service_forge-0.1.18.dist-info → service_forge-0.1.39.dist-info}/WHEEL +0 -0
- {service_forge-0.1.18.dist-info → service_forge-0.1.39.dist-info}/entry_points.txt +0 -0
|
@@ -6,9 +6,11 @@ from loguru import logger
|
|
|
6
6
|
from service_forge.workflow.trigger import Trigger
|
|
7
7
|
from typing import AsyncIterator, Any
|
|
8
8
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
9
|
+
from starlette.websockets import WebSocketState
|
|
9
10
|
from service_forge.workflow.port import Port
|
|
10
11
|
from google.protobuf.message import Message
|
|
11
12
|
from google.protobuf.json_format import MessageToJson
|
|
13
|
+
from service_forge.api.http_api import authenticate_websocket
|
|
12
14
|
|
|
13
15
|
class WebSocketAPITrigger(Trigger):
|
|
14
16
|
DEFAULT_INPUT_PORTS = [
|
|
@@ -19,6 +21,9 @@ class WebSocketAPITrigger(Trigger):
|
|
|
19
21
|
|
|
20
22
|
DEFAULT_OUTPUT_PORTS = [
|
|
21
23
|
Port("trigger", bool),
|
|
24
|
+
Port("client_id", uuid.UUID),
|
|
25
|
+
Port("user_id", int),
|
|
26
|
+
Port("token", str),
|
|
22
27
|
Port("data", Any),
|
|
23
28
|
]
|
|
24
29
|
|
|
@@ -36,6 +41,28 @@ class WebSocketAPITrigger(Trigger):
|
|
|
36
41
|
)
|
|
37
42
|
return result
|
|
38
43
|
|
|
44
|
+
async def send_message(
|
|
45
|
+
self,
|
|
46
|
+
websocket: WebSocket,
|
|
47
|
+
type: str,
|
|
48
|
+
task_id: uuid.UUID,
|
|
49
|
+
data: Any,
|
|
50
|
+
):
|
|
51
|
+
# Check if WebSocket is closed before sending
|
|
52
|
+
if websocket.client_state != WebSocketState.CONNECTED:
|
|
53
|
+
logger.warning(f"WebSocket is closed, cannot send message for task {task_id}")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
message = {
|
|
57
|
+
"type": type,
|
|
58
|
+
"task_id": str(task_id),
|
|
59
|
+
"data": data
|
|
60
|
+
}
|
|
61
|
+
try:
|
|
62
|
+
await websocket.send_text(json.dumps(message))
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.error(f"Error sending message to WebSocket for task {task_id}: {e}")
|
|
65
|
+
|
|
39
66
|
async def handle_stream_output(
|
|
40
67
|
self,
|
|
41
68
|
websocket: WebSocket,
|
|
@@ -46,12 +73,7 @@ class WebSocketAPITrigger(Trigger):
|
|
|
46
73
|
item = await self.stream_queues[task_id].get()
|
|
47
74
|
|
|
48
75
|
if item.is_error:
|
|
49
|
-
|
|
50
|
-
"type": "stream_error",
|
|
51
|
-
"task_id": str(task_id),
|
|
52
|
-
"detail": str(item.result)
|
|
53
|
-
}
|
|
54
|
-
await websocket.send_text(json.dumps(error_response))
|
|
76
|
+
await self.send_message(websocket, "stream_error", task_id, str(item.result))
|
|
55
77
|
break
|
|
56
78
|
|
|
57
79
|
if item.is_end:
|
|
@@ -65,18 +87,9 @@ class WebSocketAPITrigger(Trigger):
|
|
|
65
87
|
data = serialized
|
|
66
88
|
else:
|
|
67
89
|
data = serialized
|
|
68
|
-
|
|
69
|
-
end_response = {
|
|
70
|
-
"type": "stream_end",
|
|
71
|
-
"task_id": str(task_id),
|
|
72
|
-
"data": data
|
|
73
|
-
}
|
|
90
|
+
await self.send_message(websocket, "stream_end", task_id, data)
|
|
74
91
|
else:
|
|
75
|
-
|
|
76
|
-
"type": "stream_end",
|
|
77
|
-
"task_id": str(task_id)
|
|
78
|
-
}
|
|
79
|
-
await websocket.send_text(json.dumps(end_response))
|
|
92
|
+
await self.send_message(websocket, "stream_end", task_id, None)
|
|
80
93
|
break
|
|
81
94
|
|
|
82
95
|
# Send stream data
|
|
@@ -89,23 +102,10 @@ class WebSocketAPITrigger(Trigger):
|
|
|
89
102
|
else:
|
|
90
103
|
data = serialized
|
|
91
104
|
|
|
92
|
-
|
|
93
|
-
"type": "stream",
|
|
94
|
-
"task_id": str(task_id),
|
|
95
|
-
"data": data
|
|
96
|
-
}
|
|
97
|
-
await websocket.send_text(json.dumps(stream_response))
|
|
105
|
+
await self.send_message(websocket, "stream", task_id, data)
|
|
98
106
|
except Exception as e:
|
|
99
107
|
logger.error(f"Error handling stream output for task {task_id}: {e}")
|
|
100
|
-
|
|
101
|
-
"type": "stream_error",
|
|
102
|
-
"task_id": str(task_id),
|
|
103
|
-
"detail": str(e)
|
|
104
|
-
}
|
|
105
|
-
try:
|
|
106
|
-
await websocket.send_text(json.dumps(error_response))
|
|
107
|
-
except Exception:
|
|
108
|
-
pass
|
|
108
|
+
await self.send_message(websocket, "stream_error", task_id, str(e))
|
|
109
109
|
finally:
|
|
110
110
|
if task_id in self.stream_queues:
|
|
111
111
|
del self.stream_queues[task_id]
|
|
@@ -114,16 +114,20 @@ class WebSocketAPITrigger(Trigger):
|
|
|
114
114
|
self,
|
|
115
115
|
websocket: WebSocket,
|
|
116
116
|
data_type: type,
|
|
117
|
+
client_id: str,
|
|
117
118
|
message_data: dict,
|
|
118
119
|
):
|
|
119
120
|
task_id = uuid.uuid4()
|
|
120
|
-
self.result_queues[task_id] = asyncio.Queue()
|
|
121
|
+
# self.result_queues[task_id] = asyncio.Queue()
|
|
121
122
|
self.stream_queues[task_id] = asyncio.Queue()
|
|
122
123
|
|
|
124
|
+
logger.info(f'user_id {getattr(websocket.state, "user_id", None)} token {getattr(websocket.state, "auth_token", None)}')
|
|
125
|
+
|
|
123
126
|
if data_type is Any:
|
|
124
127
|
converted_data = message_data
|
|
125
128
|
else:
|
|
126
129
|
try:
|
|
130
|
+
# TODO: message_data is Message, need to convert to dict
|
|
127
131
|
converted_data = data_type(**message_data)
|
|
128
132
|
except Exception as e:
|
|
129
133
|
error_msg = {"error": f"Failed to convert data: {str(e)}"}
|
|
@@ -135,27 +139,46 @@ class WebSocketAPITrigger(Trigger):
|
|
|
135
139
|
|
|
136
140
|
self.trigger_queue.put_nowait({
|
|
137
141
|
"id": task_id,
|
|
142
|
+
"user_id": getattr(websocket.state, "user_id", None),
|
|
143
|
+
"token": getattr(websocket.state, "auth_token", None),
|
|
138
144
|
"data": converted_data,
|
|
145
|
+
"client_id": client_id,
|
|
139
146
|
})
|
|
140
147
|
|
|
141
148
|
# The stream handler will send all messages including stream_end when workflow completes
|
|
142
149
|
|
|
143
150
|
def _setup_websocket(self, app: FastAPI, path: str, data_type: type) -> None:
|
|
144
151
|
async def websocket_handler(websocket: WebSocket):
|
|
152
|
+
websocket.state.user_id = websocket.headers.get("X-User-ID") or "0"
|
|
153
|
+
websocket.state.auth_token = websocket.headers.get("X-User-Token") or ""
|
|
154
|
+
logger.info(f'user_id {websocket.state.user_id} token {websocket.state.auth_token}')
|
|
155
|
+
# Authenticate WebSocket connection before accepting
|
|
156
|
+
# Get trusted_domain from app.state if available
|
|
157
|
+
# trusted_domain = getattr(app.state, "trusted_domain", "ring.shiweinan.com")
|
|
158
|
+
# enable_auth = getattr(app.state, "enable_auth_middleware", True)
|
|
159
|
+
|
|
160
|
+
# if enable_auth:
|
|
161
|
+
# await authenticate_websocket(websocket, trusted_domain)
|
|
162
|
+
# else:
|
|
163
|
+
# # If auth is disabled, set default values
|
|
164
|
+
# websocket.state.user_id = websocket.headers.get("X-User-ID", "0")
|
|
165
|
+
# websocket.state.auth_token = websocket.headers.get("X-User-Token")
|
|
166
|
+
|
|
145
167
|
await websocket.accept()
|
|
168
|
+
|
|
169
|
+
client_id = uuid.uuid4()
|
|
146
170
|
|
|
147
171
|
try:
|
|
148
172
|
while True:
|
|
149
|
-
|
|
150
|
-
data = await websocket.receive_text()
|
|
173
|
+
data = await websocket.receive()
|
|
151
174
|
try:
|
|
152
|
-
message = json.loads(data)
|
|
153
|
-
|
|
175
|
+
# message = json.loads(data)
|
|
154
176
|
# Handle the message and trigger workflow
|
|
155
177
|
await self.handle_websocket_message(
|
|
156
178
|
websocket,
|
|
157
179
|
data_type,
|
|
158
|
-
|
|
180
|
+
client_id,
|
|
181
|
+
data
|
|
159
182
|
)
|
|
160
183
|
except json.JSONDecodeError:
|
|
161
184
|
error_msg = {"error": "Invalid JSON format"}
|
|
@@ -169,6 +192,8 @@ class WebSocketAPITrigger(Trigger):
|
|
|
169
192
|
except Exception as e:
|
|
170
193
|
logger.error(f"WebSocket connection error: {e}")
|
|
171
194
|
|
|
195
|
+
print("DISCONNECTED")
|
|
196
|
+
|
|
172
197
|
app.websocket(path)(websocket_handler)
|
|
173
198
|
|
|
174
199
|
async def _run(self, app: FastAPI, path: str, data_type: type) -> AsyncIterator[bool]:
|
|
@@ -179,7 +204,10 @@ class WebSocketAPITrigger(Trigger):
|
|
|
179
204
|
while True:
|
|
180
205
|
try:
|
|
181
206
|
trigger = await self.trigger_queue.get()
|
|
207
|
+
self.prepare_output_edges(self.get_output_port_by_name('user_id'), trigger['user_id'])
|
|
208
|
+
self.prepare_output_edges(self.get_output_port_by_name('token'), trigger['token'])
|
|
182
209
|
self.prepare_output_edges(self.get_output_port_by_name('data'), trigger['data'])
|
|
210
|
+
self.prepare_output_edges(self.get_output_port_by_name('client_id'), trigger['client_id'])
|
|
183
211
|
yield self.trigger(trigger['id'])
|
|
184
212
|
except Exception as e:
|
|
185
213
|
logger.error(f"Error in WebSocketAPITrigger._run: {e}")
|
|
@@ -2,17 +2,30 @@ from __future__ import annotations
|
|
|
2
2
|
import traceback
|
|
3
3
|
import asyncio
|
|
4
4
|
import uuid
|
|
5
|
-
from typing import AsyncIterator, Awaitable, Callable, Any
|
|
6
|
-
from loguru import logger
|
|
7
5
|
from copy import deepcopy
|
|
6
|
+
from typing import Any, AsyncIterator, Awaitable, Callable
|
|
7
|
+
|
|
8
|
+
from loguru import logger
|
|
9
|
+
from opentelemetry import context as otel_context_api
|
|
10
|
+
from opentelemetry import trace
|
|
11
|
+
from opentelemetry.trace import SpanKind
|
|
12
|
+
|
|
13
|
+
from ..db.database import DatabaseManager
|
|
14
|
+
from ..execution_context import (
|
|
15
|
+
ExecutionContext,
|
|
16
|
+
get_current_context,
|
|
17
|
+
reset_current_context,
|
|
18
|
+
set_current_context,
|
|
19
|
+
)
|
|
20
|
+
from .edge import Edge
|
|
8
21
|
from .node import Node
|
|
9
22
|
from .port import Port
|
|
10
23
|
from .trigger import Trigger
|
|
11
|
-
from .edge import Edge
|
|
12
24
|
from ..db.database import DatabaseManager
|
|
13
25
|
from ..utils.workflow_clone import workflow_clone
|
|
14
26
|
from .workflow_callback import WorkflowCallback, BuiltinWorkflowCallback, CallbackEvent
|
|
15
27
|
from .workflow_config import WorkflowConfig
|
|
28
|
+
from .context import Context
|
|
16
29
|
|
|
17
30
|
class Workflow:
|
|
18
31
|
def __init__(
|
|
@@ -27,10 +40,14 @@ class Workflow:
|
|
|
27
40
|
database_manager: DatabaseManager = None,
|
|
28
41
|
max_concurrent_runs: int = 10,
|
|
29
42
|
callbacks: list[WorkflowCallback] = [],
|
|
43
|
+
debug_version: bool = False, # 是否为debug过程中的临时版本
|
|
30
44
|
|
|
31
45
|
# for run
|
|
32
46
|
task_id: uuid.UUID = None,
|
|
33
47
|
real_trigger_node: Trigger = None,
|
|
48
|
+
|
|
49
|
+
# global variables
|
|
50
|
+
global_context: Context = None,
|
|
34
51
|
) -> None:
|
|
35
52
|
self.id = id
|
|
36
53
|
self.config = config
|
|
@@ -46,9 +63,12 @@ class Workflow:
|
|
|
46
63
|
self.max_concurrent_runs = max_concurrent_runs
|
|
47
64
|
self.run_semaphore = asyncio.Semaphore(max_concurrent_runs)
|
|
48
65
|
self.callbacks = callbacks
|
|
66
|
+
self.debug_version = debug_version
|
|
49
67
|
self.task_id = task_id
|
|
50
68
|
self.real_trigger_node = real_trigger_node
|
|
69
|
+
self.global_context = global_context
|
|
51
70
|
self._validate()
|
|
71
|
+
self._tracer = trace.get_tracer("service_forge.workflow")
|
|
52
72
|
|
|
53
73
|
@property
|
|
54
74
|
def name(self) -> str:
|
|
@@ -74,6 +94,8 @@ class Workflow:
|
|
|
74
94
|
await callback.on_workflow_start(*args, **kwargs)
|
|
75
95
|
elif callback_type == CallbackEvent.ON_WORKFLOW_END:
|
|
76
96
|
await callback.on_workflow_end(*args, **kwargs)
|
|
97
|
+
elif callback_type == CallbackEvent.ON_WORKFLOW_ERROR:
|
|
98
|
+
await callback.on_workflow_error(*args, **kwargs)
|
|
77
99
|
elif callback_type == CallbackEvent.ON_NODE_START:
|
|
78
100
|
await callback.on_node_start(*args, **kwargs)
|
|
79
101
|
elif callback_type == CallbackEvent.ON_NODE_END:
|
|
@@ -90,8 +112,7 @@ class Workflow:
|
|
|
90
112
|
for node in nodes:
|
|
91
113
|
self.nodes.remove(node)
|
|
92
114
|
|
|
93
|
-
def load_config(self) -> None:
|
|
94
|
-
...
|
|
115
|
+
def load_config(self) -> None: ...
|
|
95
116
|
|
|
96
117
|
def _validate(self) -> None:
|
|
97
118
|
# DAG
|
|
@@ -117,7 +138,7 @@ class Workflow:
|
|
|
117
138
|
raise ValueError("Multiple trigger nodes found in workflow.")
|
|
118
139
|
return trigger_nodes[0]
|
|
119
140
|
|
|
120
|
-
async def _run_node_with_callbacks(self, node: Node) ->
|
|
141
|
+
async def _run_node_with_callbacks(self, node: Node) -> bool:
|
|
121
142
|
await self.call_callbacks(CallbackEvent.ON_NODE_START, node=node)
|
|
122
143
|
|
|
123
144
|
try:
|
|
@@ -126,8 +147,13 @@ class Workflow:
|
|
|
126
147
|
await self.handle_node_stream_output(node, result)
|
|
127
148
|
elif asyncio.iscoroutine(result):
|
|
128
149
|
await result
|
|
150
|
+
except Exception as e:
|
|
151
|
+
await self.call_callbacks(CallbackEvent.ON_WORKFLOW_ERROR, workflow=self, node=node, error=e)
|
|
152
|
+
logger.error(f"Error when running node {node.name}: {str(e)}, task_id: {self.task_id}")
|
|
153
|
+
return False
|
|
129
154
|
finally:
|
|
130
155
|
await self.call_callbacks(CallbackEvent.ON_NODE_END, node=node)
|
|
156
|
+
return True
|
|
131
157
|
|
|
132
158
|
async def run_after_trigger(self) -> Any:
|
|
133
159
|
logger.info(f"Running workflow: {self.name}")
|
|
@@ -138,30 +164,41 @@ class Workflow:
|
|
|
138
164
|
for edge in self.get_trigger_node().output_edges:
|
|
139
165
|
edge.end_port.trigger()
|
|
140
166
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
for
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
167
|
+
for input_port in self.input_ports:
|
|
168
|
+
if input_port.value is not None:
|
|
169
|
+
input_port.port.node.fill_input(input_port.port, input_port.value)
|
|
170
|
+
|
|
171
|
+
for node in self.nodes:
|
|
172
|
+
for key in node.AUTO_FILL_INPUT_PORTS:
|
|
173
|
+
if key[0] not in [edge.end_port.name for edge in node.input_edges]:
|
|
174
|
+
node.fill_input_by_name(key[0], key[1])
|
|
175
|
+
|
|
176
|
+
while self.ready_nodes:
|
|
177
|
+
nodes = self.ready_nodes.copy()
|
|
178
|
+
self.ready_nodes = []
|
|
179
|
+
|
|
180
|
+
tasks = []
|
|
181
|
+
for node in nodes:
|
|
182
|
+
tasks.append(asyncio.create_task(self._run_node_with_callbacks(node)))
|
|
183
|
+
|
|
184
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
185
|
+
|
|
186
|
+
for i, result in enumerate(results):
|
|
187
|
+
if isinstance(result, Exception):
|
|
188
|
+
for task in tasks:
|
|
189
|
+
if not task.done():
|
|
190
|
+
task.cancel()
|
|
191
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
192
|
+
return
|
|
193
|
+
# raise result
|
|
194
|
+
elif result is False:
|
|
195
|
+
logger.error(f"Node execution failed, stopping workflow: {nodes[i].name}")
|
|
196
|
+
for task in tasks:
|
|
197
|
+
if not task.done():
|
|
198
|
+
task.cancel()
|
|
199
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
200
|
+
return
|
|
201
|
+
# raise RuntimeError(f"Workflow stopped due to node execution failure: {nodes[i].name}")
|
|
165
202
|
|
|
166
203
|
if len(self.output_ports) > 0:
|
|
167
204
|
if len(self.output_ports) == 1:
|
|
@@ -178,16 +215,82 @@ class Workflow:
|
|
|
178
215
|
else:
|
|
179
216
|
await self.call_callbacks(CallbackEvent.ON_WORKFLOW_END, workflow=self, output=None)
|
|
180
217
|
|
|
218
|
+
|
|
181
219
|
async def _run(self, task_id: uuid.UUID, trigger_node: Trigger) -> None:
|
|
182
220
|
async with self.run_semaphore:
|
|
221
|
+
base_context = get_current_context()
|
|
222
|
+
|
|
223
|
+
# 尝试从 trigger_node 上取父 trace context(如果存在)
|
|
224
|
+
trigger_parent_context = None
|
|
225
|
+
if hasattr(trigger_node, "task_contexts"):
|
|
226
|
+
trigger_parent_context = trigger_node.task_contexts.pop(task_id, None)
|
|
227
|
+
|
|
228
|
+
parent_context = (
|
|
229
|
+
trigger_parent_context
|
|
230
|
+
or (
|
|
231
|
+
base_context.trace_context
|
|
232
|
+
if base_context and base_context.trace_context
|
|
233
|
+
else otel_context_api.get_current()
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
span_name = f"Workflow {self.name}"
|
|
238
|
+
token = None
|
|
239
|
+
|
|
183
240
|
try:
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
241
|
+
with self._tracer.start_as_current_span(
|
|
242
|
+
span_name,
|
|
243
|
+
context=parent_context,
|
|
244
|
+
kind=SpanKind.INTERNAL,
|
|
245
|
+
) as span:
|
|
246
|
+
span.set_attribute("workflow.name", self.name)
|
|
247
|
+
span.set_attribute("workflow.task_id", str(task_id))
|
|
248
|
+
span.set_attribute("service.name", "service_forge")
|
|
249
|
+
|
|
250
|
+
execution_context = ExecutionContext(
|
|
251
|
+
trace_context=otel_context_api.get_current(),
|
|
252
|
+
span=span,
|
|
253
|
+
metadata={
|
|
254
|
+
**(base_context.metadata if base_context else {}),
|
|
255
|
+
"workflow_name": self.name,
|
|
256
|
+
"task_id": str(task_id),
|
|
257
|
+
},
|
|
258
|
+
)
|
|
259
|
+
token = set_current_context(execution_context)
|
|
260
|
+
|
|
261
|
+
new_workflow = self._clone(task_id, trigger_node)
|
|
262
|
+
|
|
263
|
+
# run_after_trigger 当前实现没有 return,result 会是 None
|
|
264
|
+
result = await new_workflow.run_after_trigger()
|
|
265
|
+
|
|
266
|
+
# 将结果回传给等待方(如果有队列)
|
|
267
|
+
if hasattr(trigger_node, "result_queues") and task_id in trigger_node.result_queues:
|
|
268
|
+
trigger_node.result_queues[task_id].put_nowait(result)
|
|
269
|
+
|
|
270
|
+
# TODO: clear new_workflow
|
|
271
|
+
for node in new_workflow.nodes:
|
|
272
|
+
await node.clear()
|
|
187
273
|
|
|
188
274
|
except Exception as e:
|
|
189
|
-
|
|
190
|
-
|
|
275
|
+
await self.call_callbacks(CallbackEvent.ON_WORKFLOW_ERROR, workflow=self, node=None, error=e)
|
|
276
|
+
# error_msg = f"Error running workflow: {str(e)}, {traceback.format_exc()}"
|
|
277
|
+
# logger.error(error_msg)
|
|
278
|
+
# await self.call_callbacks(CallbackEvent.ON_WORKFLOW_END, workflow=self, node=None, error=e)
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
# 更新任务状态为失败 + WebSocket 通知
|
|
282
|
+
websocket_manager.task_manager.fail_task(task_id, error_msg)
|
|
283
|
+
await websocket_manager.send_execution_error(task_id, "workflow", error_msg)
|
|
284
|
+
|
|
285
|
+
# 异常也回传给等待方,避免对方一直 await
|
|
286
|
+
if hasattr(trigger_node, "result_queues") and task_id in trigger_node.result_queues:
|
|
287
|
+
trigger_node.result_queues[task_id].put_nowait(e)
|
|
288
|
+
|
|
289
|
+
# 将异常继续抛出,便于上层感知失败
|
|
290
|
+
raise
|
|
291
|
+
finally:
|
|
292
|
+
if token is not None:
|
|
293
|
+
reset_current_context(token)
|
|
191
294
|
|
|
192
295
|
async def run(self):
|
|
193
296
|
tasks = []
|
|
@@ -203,9 +306,9 @@ class Workflow:
|
|
|
203
306
|
trigger = self.get_trigger_node()
|
|
204
307
|
await trigger._stop()
|
|
205
308
|
|
|
206
|
-
def trigger(self, trigger_name: str, **kwargs) -> uuid.UUID:
|
|
309
|
+
def trigger(self, trigger_name: str, assigned_task_id: uuid.UUID | None, **kwargs) -> uuid.UUID:
|
|
207
310
|
trigger = self.get_trigger_node()
|
|
208
|
-
task_id = uuid.uuid4()
|
|
311
|
+
task_id = assigned_task_id or uuid.uuid4()
|
|
209
312
|
for key, value in kwargs.items():
|
|
210
313
|
trigger.prepare_output_edges(key, value)
|
|
211
314
|
task = asyncio.create_task(self._run(task_id, trigger))
|
|
@@ -31,7 +31,7 @@ class WorkflowCallback:
|
|
|
31
31
|
pass
|
|
32
32
|
|
|
33
33
|
@abstractmethod
|
|
34
|
-
async def on_workflow_error(self, workflow: Workflow, error: Any) -> None:
|
|
34
|
+
async def on_workflow_error(self, workflow: Workflow, node: Node, error: Any) -> None:
|
|
35
35
|
pass
|
|
36
36
|
|
|
37
37
|
@abstractmethod
|
|
@@ -90,7 +90,7 @@ class BuiltinWorkflowCallback(WorkflowCallback):
|
|
|
90
90
|
logger.error(f"发送 workflow_end 消息到 websocket 失败: {e}")
|
|
91
91
|
|
|
92
92
|
@override
|
|
93
|
-
async def on_workflow_error(self, workflow: Workflow, error: Any) -> None:
|
|
93
|
+
async def on_workflow_error(self, workflow: Workflow, node: Node | None, error: Any) -> None:
|
|
94
94
|
workflow_result = WorkflowResult(result=error, is_end=False, is_error=True)
|
|
95
95
|
|
|
96
96
|
if workflow.task_id in workflow.real_trigger_node.result_queues:
|
|
@@ -103,6 +103,7 @@ class BuiltinWorkflowCallback(WorkflowCallback):
|
|
|
103
103
|
message = {
|
|
104
104
|
"type": "workflow_error",
|
|
105
105
|
"task_id": str(workflow.task_id),
|
|
106
|
+
"node": node.name if node else None,
|
|
106
107
|
"error": self._serialize_result(error),
|
|
107
108
|
"is_end": False,
|
|
108
109
|
"is_error": True
|
|
@@ -113,11 +114,33 @@ class BuiltinWorkflowCallback(WorkflowCallback):
|
|
|
113
114
|
|
|
114
115
|
@override
|
|
115
116
|
async def on_node_start(self, node: Node) -> None:
|
|
116
|
-
|
|
117
|
+
try:
|
|
118
|
+
manager = self._get_websocket_manager()
|
|
119
|
+
message = {
|
|
120
|
+
"type": "node_start",
|
|
121
|
+
"task_id": str(node.workflow.task_id),
|
|
122
|
+
"node": node.name,
|
|
123
|
+
"is_end": False,
|
|
124
|
+
"is_error": False
|
|
125
|
+
}
|
|
126
|
+
await manager.send_to_task(node.workflow.task_id, message)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
logger.error(f"发送 node_start 消息到 websocket 失败: {e}")
|
|
117
129
|
|
|
118
130
|
@override
|
|
119
131
|
async def on_node_end(self, node: Node) -> None:
|
|
120
|
-
|
|
132
|
+
try:
|
|
133
|
+
manager = self._get_websocket_manager()
|
|
134
|
+
message = {
|
|
135
|
+
"type": "node_end",
|
|
136
|
+
"task_id": str(node.workflow.task_id),
|
|
137
|
+
"node": node.name,
|
|
138
|
+
"is_end": False,
|
|
139
|
+
"is_error": False
|
|
140
|
+
}
|
|
141
|
+
await manager.send_to_task(node.workflow.task_id, message)
|
|
142
|
+
except Exception as e:
|
|
143
|
+
logger.error(f"发送 node_end 消息到 websocket 失败: {e}")
|
|
121
144
|
|
|
122
145
|
@override
|
|
123
146
|
async def on_node_stream_output(self, node: Node, output: Any) -> None:
|
|
@@ -3,6 +3,8 @@ from omegaconf import OmegaConf
|
|
|
3
3
|
from typing import Callable, Awaitable, AsyncIterator, Any
|
|
4
4
|
from copy import deepcopy
|
|
5
5
|
|
|
6
|
+
from pydantic import ValidationError
|
|
7
|
+
|
|
6
8
|
from service_forge.workflow.workflow_callback import BuiltinWorkflowCallback
|
|
7
9
|
from .workflow import Workflow
|
|
8
10
|
from .workflow_group import WorkflowGroup, WORKFLOW_DEFAULT_VERSION
|
|
@@ -53,6 +55,7 @@ def create_workflow(
|
|
|
53
55
|
_handle_stream_output: Callable[[str, AsyncIterator[str]], Awaitable[None]] | None = None,
|
|
54
56
|
_handle_query_user: Callable[[str, str], Awaitable[str]] | None = None,
|
|
55
57
|
database_manager: DatabaseManager = None,
|
|
58
|
+
debug_version: bool = False,
|
|
56
59
|
) -> Workflow:
|
|
57
60
|
if config is None:
|
|
58
61
|
if config_path is not None:
|
|
@@ -71,6 +74,8 @@ def create_workflow(
|
|
|
71
74
|
database_manager = database_manager,
|
|
72
75
|
# TODO: max_concurrent_runs
|
|
73
76
|
callbacks = [BuiltinWorkflowCallback()],
|
|
77
|
+
debug_version = debug_version,
|
|
78
|
+
global_context = Context(variables={}),
|
|
74
79
|
)
|
|
75
80
|
|
|
76
81
|
nodes: dict[str, Node] = {}
|
|
@@ -207,6 +212,7 @@ def create_workflow_group(
|
|
|
207
212
|
_handle_stream_output: Callable[[str, AsyncIterator[str]], Awaitable[None]] = None,
|
|
208
213
|
_handle_query_user: Callable[[str, str], Awaitable[str]] = None,
|
|
209
214
|
database_manager: DatabaseManager = None,
|
|
215
|
+
debug_version: bool = False,
|
|
210
216
|
) -> WorkflowGroup:
|
|
211
217
|
|
|
212
218
|
if config is None:
|
|
@@ -218,6 +224,12 @@ def create_workflow_group(
|
|
|
218
224
|
else:
|
|
219
225
|
raise ValueError("Either config_path or config must be provided")
|
|
220
226
|
|
|
227
|
+
if isinstance(config, dict):
|
|
228
|
+
try:
|
|
229
|
+
config = WorkflowConfig.model_validate(config)
|
|
230
|
+
except ValidationError:
|
|
231
|
+
config = WorkflowGroupConfig.model_validate(config)
|
|
232
|
+
|
|
221
233
|
if type(config) == WorkflowConfig:
|
|
222
234
|
workflow = create_workflow(
|
|
223
235
|
config_path=config_path if config_path else None,
|
|
@@ -226,6 +238,7 @@ def create_workflow_group(
|
|
|
226
238
|
_handle_stream_output=_handle_stream_output,
|
|
227
239
|
_handle_query_user=_handle_query_user,
|
|
228
240
|
database_manager=database_manager,
|
|
241
|
+
debug_version=debug_version,
|
|
229
242
|
)
|
|
230
243
|
return WorkflowGroup(workflows=[workflow], main_workflow_name=workflow.name, main_workflow_version=workflow.version)
|
|
231
244
|
elif type(config) == WorkflowGroupConfig:
|
|
@@ -242,5 +255,6 @@ def create_workflow_group(
|
|
|
242
255
|
_handle_stream_output=_handle_stream_output,
|
|
243
256
|
_handle_query_user=_handle_query_user,
|
|
244
257
|
database_manager=database_manager,
|
|
258
|
+
debug_version=debug_version,
|
|
245
259
|
))
|
|
246
260
|
return workflows
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: service-forge
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.39
|
|
4
4
|
Summary: Add your description here
|
|
5
5
|
Author-email: euxcet <zcc.qwer@gmail.com>
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -16,6 +16,9 @@ Requires-Dist: kubernetes>=28.0.0
|
|
|
16
16
|
Requires-Dist: loguru>=0.7.3
|
|
17
17
|
Requires-Dist: omegaconf>=2.3.0
|
|
18
18
|
Requires-Dist: openai>=2.3.0
|
|
19
|
+
Requires-Dist: opentelemetry-api>=1.38.0
|
|
20
|
+
Requires-Dist: opentelemetry-exporter-otlp>=1.38.0
|
|
21
|
+
Requires-Dist: opentelemetry-sdk>=1.38.0
|
|
19
22
|
Requires-Dist: protobuf>=6.33.1
|
|
20
23
|
Requires-Dist: psycopg2-binary>=2.9.11
|
|
21
24
|
Requires-Dist: pydantic>=2.12.0
|