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.

Files changed (80) hide show
  1. service_forge/__init__.py +0 -0
  2. service_forge/api/deprecated_websocket_api.py +91 -33
  3. service_forge/api/deprecated_websocket_manager.py +70 -53
  4. service_forge/api/http_api.py +205 -55
  5. service_forge/api/kafka_api.py +113 -25
  6. service_forge/api/routers/meta_api/meta_api_router.py +57 -0
  7. service_forge/api/routers/service/service_router.py +42 -6
  8. service_forge/api/routers/trace/trace_router.py +326 -0
  9. service_forge/api/routers/websocket/websocket_router.py +69 -1
  10. service_forge/api/service_studio.py +9 -0
  11. service_forge/db/database.py +17 -0
  12. service_forge/execution_context.py +106 -0
  13. service_forge/frontend/static/assets/CreateNewNodeDialog-DkrEMxSH.js +1 -0
  14. service_forge/frontend/static/assets/CreateNewNodeDialog-DwFcBiGp.css +1 -0
  15. service_forge/frontend/static/assets/EditorSidePanel-BNVms9Fq.css +1 -0
  16. service_forge/frontend/static/assets/EditorSidePanel-DZbB3ILL.js +1 -0
  17. service_forge/frontend/static/assets/FeedbackPanel-CC8HX7Yo.js +1 -0
  18. service_forge/frontend/static/assets/FeedbackPanel-ClgniIVk.css +1 -0
  19. service_forge/frontend/static/assets/FormattedCodeViewer.vue_vue_type_script_setup_true_lang-BNuI1NCs.js +1 -0
  20. service_forge/frontend/static/assets/NodeDetailWrapper-BqFFM7-r.js +1 -0
  21. service_forge/frontend/static/assets/NodeDetailWrapper-pZBxv3J0.css +1 -0
  22. service_forge/frontend/static/assets/TestRunningDialog-D0GrCoYs.js +1 -0
  23. service_forge/frontend/static/assets/TestRunningDialog-dhXOsPgH.css +1 -0
  24. service_forge/frontend/static/assets/TracePanelWrapper-B9zvDSc_.js +1 -0
  25. service_forge/frontend/static/assets/TracePanelWrapper-BiednCrq.css +1 -0
  26. service_forge/frontend/static/assets/WorkflowEditor-CcaGGbko.js +3 -0
  27. service_forge/frontend/static/assets/WorkflowEditor-CmasOOYK.css +1 -0
  28. service_forge/frontend/static/assets/WorkflowList-Copuwi-a.css +1 -0
  29. service_forge/frontend/static/assets/WorkflowList-LrRJ7B7h.js +1 -0
  30. service_forge/frontend/static/assets/WorkflowStudio-CthjgII2.css +1 -0
  31. service_forge/frontend/static/assets/WorkflowStudio-FCyhGD4y.js +2 -0
  32. service_forge/frontend/static/assets/api-BDer3rj7.css +1 -0
  33. service_forge/frontend/static/assets/api-DyiqpKJK.js +1 -0
  34. service_forge/frontend/static/assets/code-editor-DBSql_sc.js +12 -0
  35. service_forge/frontend/static/assets/el-collapse-item-D4LG0FJ0.css +1 -0
  36. service_forge/frontend/static/assets/el-empty-D4ZqTl4F.css +1 -0
  37. service_forge/frontend/static/assets/el-form-item-BWkJzdQ_.css +1 -0
  38. service_forge/frontend/static/assets/el-input-D6B3r8CH.css +1 -0
  39. service_forge/frontend/static/assets/el-select-B0XIb2QK.css +1 -0
  40. service_forge/frontend/static/assets/el-tag-DljBBxJR.css +1 -0
  41. service_forge/frontend/static/assets/element-ui-D3x2y3TA.js +12 -0
  42. service_forge/frontend/static/assets/elkjs-Dm5QV7uy.js +24 -0
  43. service_forge/frontend/static/assets/highlightjs-D4ATuRwX.js +3 -0
  44. service_forge/frontend/static/assets/index-BMvodlwc.js +2 -0
  45. service_forge/frontend/static/assets/index-CjSe8i2q.css +1 -0
  46. service_forge/frontend/static/assets/js-yaml-yTPt38rv.js +32 -0
  47. service_forge/frontend/static/assets/time-DKCKV6Ug.js +1 -0
  48. service_forge/frontend/static/assets/ui-components-DQ7-U3pr.js +1 -0
  49. service_forge/frontend/static/assets/vue-core-DL-LgTX0.js +1 -0
  50. service_forge/frontend/static/assets/vue-flow-Dn7R8GPr.js +39 -0
  51. service_forge/frontend/static/index.html +16 -0
  52. service_forge/frontend/static/vite.svg +1 -0
  53. service_forge/model/meta_api/__init__.py +0 -0
  54. service_forge/model/meta_api/schema.py +29 -0
  55. service_forge/model/trace.py +82 -0
  56. service_forge/service.py +39 -11
  57. service_forge/service_config.py +14 -0
  58. service_forge/sft/cli.py +39 -0
  59. service_forge/sft/cmd/remote_deploy.py +160 -0
  60. service_forge/sft/cmd/remote_list_tars.py +111 -0
  61. service_forge/sft/config/injector.py +54 -7
  62. service_forge/sft/config/injector_default_files.py +13 -1
  63. service_forge/sft/config/sf_metadata.py +31 -27
  64. service_forge/sft/config/sft_config.py +18 -0
  65. service_forge/sft/util/assert_util.py +0 -1
  66. service_forge/telemetry.py +66 -0
  67. service_forge/utils/default_type_converter.py +1 -1
  68. service_forge/utils/type_converter.py +5 -0
  69. service_forge/utils/workflow_clone.py +1 -0
  70. service_forge/workflow/node.py +274 -27
  71. service_forge/workflow/triggers/fast_api_trigger.py +64 -28
  72. service_forge/workflow/triggers/websocket_api_trigger.py +66 -38
  73. service_forge/workflow/workflow.py +140 -37
  74. service_forge/workflow/workflow_callback.py +27 -4
  75. service_forge/workflow/workflow_factory.py +14 -0
  76. {service_forge-0.1.18.dist-info → service_forge-0.1.39.dist-info}/METADATA +4 -1
  77. service_forge-0.1.39.dist-info/RECORD +134 -0
  78. service_forge-0.1.18.dist-info/RECORD +0 -83
  79. {service_forge-0.1.18.dist-info → service_forge-0.1.39.dist-info}/WHEEL +0 -0
  80. {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
- error_response = {
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
- end_response = {
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
- stream_response = {
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
- error_response = {
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
- # Receive message from client
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
- message
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) -> None:
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
- try:
142
- for input_port in self.input_ports:
143
- if input_port.value is not None:
144
- input_port.port.node.fill_input(input_port.port, input_port.value)
145
-
146
- for node in self.nodes:
147
- for key in node.AUTO_FILL_INPUT_PORTS:
148
- if key[0] not in [edge.end_port.name for edge in node.input_edges]:
149
- node.fill_input_by_name(key[0], key[1])
150
-
151
- while self.ready_nodes:
152
- nodes = self.ready_nodes.copy()
153
- self.ready_nodes = []
154
-
155
- tasks = []
156
- for node in nodes:
157
- tasks.append(asyncio.create_task(self._run_node_with_callbacks(node)))
158
-
159
- await asyncio.gather(*tasks)
160
-
161
- except Exception as e:
162
- error_msg = f"Error in run_after_trigger: {str(e)}"
163
- logger.error(error_msg)
164
- raise e
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
- new_workflow = self._clone(task_id, trigger_node)
185
- await new_workflow.run_after_trigger()
186
- # TODO: clear new_workflow
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
- error_msg = f"Error running workflow: {str(e)}, {traceback.format_exc()}"
190
- logger.error(error_msg)
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.18
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