service-forge 0.1.28__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 (72) 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 +127 -53
  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 +56 -1
  10. service_forge/api/service_studio.py +9 -0
  11. service_forge/execution_context.py +106 -0
  12. service_forge/frontend/static/assets/CreateNewNodeDialog-DkrEMxSH.js +1 -0
  13. service_forge/frontend/static/assets/CreateNewNodeDialog-DwFcBiGp.css +1 -0
  14. service_forge/frontend/static/assets/EditorSidePanel-BNVms9Fq.css +1 -0
  15. service_forge/frontend/static/assets/EditorSidePanel-DZbB3ILL.js +1 -0
  16. service_forge/frontend/static/assets/FeedbackPanel-CC8HX7Yo.js +1 -0
  17. service_forge/frontend/static/assets/FeedbackPanel-ClgniIVk.css +1 -0
  18. service_forge/frontend/static/assets/FormattedCodeViewer.vue_vue_type_script_setup_true_lang-BNuI1NCs.js +1 -0
  19. service_forge/frontend/static/assets/NodeDetailWrapper-BqFFM7-r.js +1 -0
  20. service_forge/frontend/static/assets/NodeDetailWrapper-pZBxv3J0.css +1 -0
  21. service_forge/frontend/static/assets/TestRunningDialog-D0GrCoYs.js +1 -0
  22. service_forge/frontend/static/assets/TestRunningDialog-dhXOsPgH.css +1 -0
  23. service_forge/frontend/static/assets/TracePanelWrapper-B9zvDSc_.js +1 -0
  24. service_forge/frontend/static/assets/TracePanelWrapper-BiednCrq.css +1 -0
  25. service_forge/frontend/static/assets/WorkflowEditor-CcaGGbko.js +3 -0
  26. service_forge/frontend/static/assets/WorkflowEditor-CmasOOYK.css +1 -0
  27. service_forge/frontend/static/assets/WorkflowList-Copuwi-a.css +1 -0
  28. service_forge/frontend/static/assets/WorkflowList-LrRJ7B7h.js +1 -0
  29. service_forge/frontend/static/assets/WorkflowStudio-CthjgII2.css +1 -0
  30. service_forge/frontend/static/assets/WorkflowStudio-FCyhGD4y.js +2 -0
  31. service_forge/frontend/static/assets/api-BDer3rj7.css +1 -0
  32. service_forge/frontend/static/assets/api-DyiqpKJK.js +1 -0
  33. service_forge/frontend/static/assets/code-editor-DBSql_sc.js +12 -0
  34. service_forge/frontend/static/assets/el-collapse-item-D4LG0FJ0.css +1 -0
  35. service_forge/frontend/static/assets/el-empty-D4ZqTl4F.css +1 -0
  36. service_forge/frontend/static/assets/el-form-item-BWkJzdQ_.css +1 -0
  37. service_forge/frontend/static/assets/el-input-D6B3r8CH.css +1 -0
  38. service_forge/frontend/static/assets/el-select-B0XIb2QK.css +1 -0
  39. service_forge/frontend/static/assets/el-tag-DljBBxJR.css +1 -0
  40. service_forge/frontend/static/assets/element-ui-D3x2y3TA.js +12 -0
  41. service_forge/frontend/static/assets/elkjs-Dm5QV7uy.js +24 -0
  42. service_forge/frontend/static/assets/highlightjs-D4ATuRwX.js +3 -0
  43. service_forge/frontend/static/assets/index-BMvodlwc.js +2 -0
  44. service_forge/frontend/static/assets/index-CjSe8i2q.css +1 -0
  45. service_forge/frontend/static/assets/js-yaml-yTPt38rv.js +32 -0
  46. service_forge/frontend/static/assets/time-DKCKV6Ug.js +1 -0
  47. service_forge/frontend/static/assets/ui-components-DQ7-U3pr.js +1 -0
  48. service_forge/frontend/static/assets/vue-core-DL-LgTX0.js +1 -0
  49. service_forge/frontend/static/assets/vue-flow-Dn7R8GPr.js +39 -0
  50. service_forge/frontend/static/index.html +16 -0
  51. service_forge/frontend/static/vite.svg +1 -0
  52. service_forge/model/meta_api/__init__.py +0 -0
  53. service_forge/model/meta_api/schema.py +29 -0
  54. service_forge/model/trace.py +82 -0
  55. service_forge/service.py +32 -11
  56. service_forge/service_config.py +14 -0
  57. service_forge/sft/config/injector.py +32 -2
  58. service_forge/sft/config/injector_default_files.py +12 -0
  59. service_forge/sft/config/sf_metadata.py +5 -0
  60. service_forge/sft/config/sft_config.py +18 -0
  61. service_forge/telemetry.py +66 -0
  62. service_forge/workflow/node.py +266 -27
  63. service_forge/workflow/triggers/fast_api_trigger.py +61 -28
  64. service_forge/workflow/triggers/websocket_api_trigger.py +31 -10
  65. service_forge/workflow/workflow.py +87 -10
  66. service_forge/workflow/workflow_callback.py +24 -2
  67. service_forge/workflow/workflow_factory.py +13 -0
  68. {service_forge-0.1.28.dist-info → service_forge-0.1.39.dist-info}/METADATA +4 -1
  69. service_forge-0.1.39.dist-info/RECORD +134 -0
  70. service_forge-0.1.28.dist-info/RECORD +0 -85
  71. {service_forge-0.1.28.dist-info → service_forge-0.1.39.dist-info}/WHEEL +0 -0
  72. {service_forge-0.1.28.dist-info → service_forge-0.1.39.dist-info}/entry_points.txt +0 -0
@@ -2,25 +2,39 @@ from fastapi import FastAPI
2
2
  import uvicorn
3
3
  from fastapi import APIRouter
4
4
  from loguru import logger
5
+ from typing import Optional
5
6
  from urllib.parse import urlparse
6
7
  from fastapi import HTTPException, Request, WebSocket, WebSocketException
7
8
  from fastapi.middleware.cors import CORSMiddleware
8
- from fastapi.openapi.utils import get_openapi
9
+
10
+ from opentelemetry import context as otel_context_api
11
+ from opentelemetry import trace
12
+ from opentelemetry.propagate import extract
13
+ from opentelemetry.trace import SpanKind
14
+
15
+ from service_forge.api.routers.meta_api.meta_api_router import meta_api_router
16
+ from service_forge.api.routers.trace.trace_router import trace_router
17
+ from service_forge.api.service_studio import studio_static_files
9
18
  from service_forge.api.routers.websocket.websocket_router import websocket_router
10
19
  from service_forge.api.routers.service.service_router import service_router
11
20
  from service_forge.api.routers.feedback.feedback_router import router as feedback_router
12
21
  from service_forge.sft.config.sf_metadata import load_metadata
13
22
  from service_forge.sft.util.name_util import get_service_url_name
23
+ from service_forge.execution_context import (
24
+ ExecutionContext,
25
+ reset_current_context,
26
+ set_current_context,
27
+ )
14
28
 
15
29
  def is_trusted_origin(origin_host: str, host: str, trusted_root: str = "ring.shiweinan.com") -> bool:
16
30
  """
17
31
  Check if the origin host is trusted based on domain matching.
18
-
32
+
19
33
  Args:
20
34
  origin_host: The hostname from the origin header
21
35
  host: The hostname from the host header
22
36
  trusted_root: The trusted root domain (can be customized)
23
-
37
+
24
38
  Returns:
25
39
  bool: True if the origin is trusted, False otherwise
26
40
  """
@@ -30,9 +44,9 @@ def is_trusted_origin(origin_host: str, host: str, trusted_root: str = "ring.shi
30
44
 
31
45
  # Allow same domain, or subdomains under the same trusted root
32
46
  return (
33
- origin_host == host or
34
- origin_host.endswith("." + trusted_root) or
35
- host.endswith("." + trusted_root)
47
+ origin_host == host
48
+ or origin_host.endswith("." + trusted_root)
49
+ or host.endswith("." + trusted_root)
36
50
  )
37
51
 
38
52
 
@@ -45,21 +59,21 @@ def validate_auth_from_headers(
45
59
  ) -> tuple[str | None, str | None]:
46
60
  """
47
61
  Validate authentication from headers and return user_id and token.
48
-
62
+
49
63
  Args:
50
64
  headers: Dictionary of headers (can be from Request or WebSocket)
51
65
  origin: Origin header value
52
66
  scheme: URL scheme (http/https/ws/wss)
53
67
  host: Host header value
54
68
  trusted_domain: Trusted domain for origin validation
55
-
69
+
56
70
  Returns:
57
71
  tuple: (user_id, auth_token) - user_id can be None if not authenticated and not same origin
58
72
  """
59
73
  is_same_origin = False
60
-
74
+
61
75
  logger.debug(f"origin {origin}, host:{host}")
62
-
76
+
63
77
  if origin and host:
64
78
  try:
65
79
  parsed_origin = urlparse(origin)
@@ -71,10 +85,10 @@ def validate_auth_from_headers(
71
85
  )
72
86
  except Exception:
73
87
  pass
74
-
88
+
75
89
  user_id = headers.get("X-User-ID")
76
90
  token = headers.get("X-User-Token")
77
-
91
+
78
92
  if not is_same_origin:
79
93
  # For cross-origin requests, user_id is required
80
94
  if not user_id:
@@ -91,18 +105,21 @@ async def authenticate_websocket(
91
105
  ) -> None:
92
106
  """
93
107
  Authenticate WebSocket connection and set user_id and auth_token in websocket.state.
94
-
108
+
95
109
  Args:
96
110
  websocket: WebSocket instance
97
111
  trusted_domain: Trusted domain for origin validation
98
-
112
+
99
113
  Raises:
100
114
  WebSocketException: If authentication fails
101
115
  """
116
+ if not websocket.url.path.startswith("/api"):
117
+ return
118
+
102
119
  origin = websocket.headers.get("origin") or websocket.headers.get("referer")
103
120
  scheme = websocket.url.scheme
104
121
  host = websocket.headers.get("host", "")
105
-
122
+
106
123
  user_id, token = validate_auth_from_headers(
107
124
  websocket.headers,
108
125
  origin,
@@ -110,10 +127,10 @@ async def authenticate_websocket(
110
127
  host,
111
128
  trusted_domain,
112
129
  )
113
-
130
+
114
131
  if user_id is None:
115
132
  raise WebSocketException(code=1008, reason="Unauthorized")
116
-
133
+
117
134
  websocket.state.user_id = user_id
118
135
  websocket.state.auth_token = token
119
136
 
@@ -128,14 +145,14 @@ def create_app(
128
145
  ) -> FastAPI:
129
146
  """
130
147
  Create or configure a FastAPI app with common middleware and configuration.
131
-
148
+
132
149
  Args:
133
150
  app: Optional existing FastAPI instance. If None, creates a new one.
134
151
  routers: List of APIRouter instances to include
135
152
  cors_origins: List of allowed CORS origins. Defaults to ["*"]
136
153
  enable_auth_middleware: Whether to enable authentication middleware
137
154
  trusted_domain: Trusted domain for origin validation
138
-
155
+
139
156
  Returns:
140
157
  FastAPI: Configured FastAPI application instance
141
158
  """
@@ -145,7 +162,7 @@ def create_app(
145
162
  # Configure CORS middleware
146
163
  if cors_origins is None:
147
164
  cors_origins = ["*"]
148
-
165
+
149
166
  app.add_middleware(
150
167
  CORSMiddleware,
151
168
  allow_origins=cors_origins,
@@ -154,6 +171,49 @@ def create_app(
154
171
  allow_headers=["*"],
155
172
  )
156
173
 
174
+ @app.middleware("http")
175
+ async def tracing_middleware(request: Request, call_next):
176
+ tracer = trace.get_tracer("service_forge.api.http")
177
+ try:
178
+ parent_context = extract(request.headers)
179
+ except Exception:
180
+ parent_context = otel_context_api.get_current()
181
+ if parent_context is None:
182
+ parent_context = otel_context_api.get_current()
183
+
184
+ span_name = f"HTTP {request.method} {request.url.path}"
185
+ with tracer.start_as_current_span(
186
+ span_name,
187
+ context=parent_context,
188
+ kind=SpanKind.SERVER,
189
+ ) as span:
190
+ span.set_attribute("http.method", request.method)
191
+ span.set_attribute("http.target", request.url.path)
192
+ span.set_attribute("http.scheme", request.url.scheme)
193
+ host_header = request.headers.get("host")
194
+ if host_header:
195
+ span.set_attribute("http.host", host_header)
196
+ if request.client:
197
+ span.set_attribute("net.peer.ip", request.client.host)
198
+ span.set_attribute("net.peer.port", request.client.port or 0)
199
+
200
+ execution_context = ExecutionContext(
201
+ trace_context=otel_context_api.get_current(),
202
+ span=span,
203
+ metadata={
204
+ "entrypoint": "http",
205
+ "path": request.url.path,
206
+ "method": request.method,
207
+ },
208
+ )
209
+ token = set_current_context(execution_context)
210
+ request.state.execution_context = execution_context
211
+ try:
212
+ response = await call_next(request)
213
+ finally:
214
+ reset_current_context(token)
215
+ return response
216
+
157
217
  # Include routers if provided
158
218
  if routers:
159
219
  for router in routers:
@@ -169,42 +229,53 @@ def create_app(
169
229
  # Include Feedback router
170
230
  app.include_router(feedback_router)
171
231
 
172
- # Always include Service router
232
+ # Always include Service router, Meta API Router, Trace Router, and Static Files
173
233
  app.include_router(service_router)
174
-
234
+ app.include_router(meta_api_router)
235
+ app.include_router(trace_router)
236
+ app.mount('/sdk/studio', studio_static_files)
237
+
238
+ @app.middleware("http")
239
+ async def auth_middleware(request: Request, call_next):
240
+ request.state.user_id = request.headers.get("X-User-ID") or "0"
241
+ request.state.auth_token = request.headers.get("X-User-Token") or ""
242
+ return await call_next(request)
243
+
244
+
175
245
  # Add authentication middleware if enabled
176
- if enable_auth_middleware:
177
- @app.middleware("http")
178
- async def auth_middleware(request: Request, call_next):
179
- """
180
- Authentication middleware for API routes.
181
-
182
- Validates user authentication for /api routes with origin-based
183
- trust verification and X-User-ID header validation.
184
- """
185
- if request.url.path.startswith("/api"):
186
- origin = request.headers.get("origin") or request.headers.get("referer")
187
- scheme = request.url.scheme
188
- host = request.headers.get("host", "")
189
-
190
- user_id, token = validate_auth_from_headers(
191
- request.headers,
192
- origin,
193
- scheme,
194
- host,
195
- trusted_domain,
196
- )
197
-
198
- if user_id is None:
199
- raise HTTPException(status_code=401, detail="Unauthorized")
200
-
201
- request.state.user_id = user_id
202
- request.state.auth_token = token
203
-
204
- return await call_next(request)
205
-
246
+ # if enable_auth_middleware:
247
+ # @app.middleware("http")
248
+ # async def auth_middleware(request: Request, call_next):
249
+ # """
250
+ # Authentication middleware for API routes.
251
+
252
+ # Validates user authentication for /api routes with origin-based
253
+ # trust verification and X-User-ID header validation.
254
+ # """
255
+ # if request.url.path.startswith("/api"):
256
+ # origin = request.headers.get("origin") or request.headers.get("referer")
257
+ # scheme = request.url.scheme
258
+ # host = request.headers.get("host", "")
259
+
260
+ # user_id, token = validate_auth_from_headers(
261
+ # request.headers,
262
+ # origin,
263
+ # scheme,
264
+ # host,
265
+ # trusted_domain,
266
+ # )
267
+
268
+ # if user_id is None:
269
+ # raise HTTPException(status_code=401, detail="Unauthorized")
270
+
271
+ # request.state.user_id = user_id
272
+ # request.state.auth_token = token
273
+
274
+ # return await call_next(request)
275
+
206
276
  return app
207
277
 
278
+
208
279
  async def start_fastapi_server(host: str, port: int):
209
280
  try:
210
281
  config = uvicorn.Config(
@@ -222,7 +293,10 @@ async def start_fastapi_server(host: str, port: int):
222
293
 
223
294
  try:
224
295
  metadata = load_metadata("sf-meta.yaml")
225
- fastapi_app = create_app(enable_auth_middleware=metadata.enable_auth_middleware, root_path=f"/api/v1/{get_service_url_name(metadata.name, metadata.version)}")
296
+ if metadata.mode == "debug":
297
+ fastapi_app = create_app(enable_auth_middleware=True, root_path=None)
298
+ else:
299
+ fastapi_app = create_app(enable_auth_middleware=metadata.enable_auth_middleware, root_path=f"/api/v1/{get_service_url_name(metadata.name, metadata.version)}")
226
300
  except Exception as e:
227
301
  logger.warning(f"Failed to load metadata, using default configuration: {e}")
228
302
  fastapi_app = create_app(enable_auth_middleware=True, root_path=None)
@@ -1,11 +1,24 @@
1
1
  from __future__ import annotations
2
- from typing import Callable, Any
3
- from aiokafka import AIOKafkaConsumer, AIOKafkaProducer, ConsumerRecord
2
+
4
3
  import asyncio
5
- import json
6
4
  import inspect
7
- from pydantic import BaseModel
5
+ import json
6
+ from typing import Any, Callable
7
+
8
+ from aiokafka import AIOKafkaConsumer, AIOKafkaProducer, ConsumerRecord
8
9
  from loguru import logger
10
+ from opentelemetry import context as otel_context_api
11
+ from opentelemetry import trace
12
+ from opentelemetry.propagate import extract, inject
13
+ from opentelemetry.trace import SpanKind
14
+
15
+ from service_forge.execution_context import (
16
+ ExecutionContext,
17
+ set_current_context,
18
+ get_current_context,
19
+ reset_current_context,
20
+ )
21
+
9
22
 
10
23
  class KafkaApp:
11
24
  def __init__(self, bootstrap_servers: str = None):
@@ -15,15 +28,21 @@ class KafkaApp:
15
28
  self._producer: AIOKafkaProducer = None
16
29
  self._lock = asyncio.Lock()
17
30
  self._running = False
31
+ self._tracer = trace.get_tracer("service_forge.api.kafka")
18
32
 
19
33
  def kafka_input(self, topic: str, data_type: type, group_id: str):
20
34
  def decorator(func: Callable):
21
35
  self._handlers[topic] = (func, data_type, group_id)
22
- logger.info(f"Registered Kafka input handler for topic '{topic}', data_type: {data_type}")
36
+ logger.info(
37
+ f"Registered Kafka input handler for topic '{topic}', data_type: {data_type}"
38
+ )
23
39
 
24
40
  if self._running:
25
- asyncio.create_task(self._start_consumer(topic, func, data_type, group_id))
41
+ asyncio.create_task(
42
+ self._start_consumer(topic, func, data_type, group_id)
43
+ )
26
44
  return func
45
+
27
46
  return decorator
28
47
 
29
48
  def set_bootstrap_servers(self, bootstrap_servers: str) -> None:
@@ -40,13 +59,17 @@ class KafkaApp:
40
59
  async with self._lock:
41
60
  for topic, (handler, data_type, group_id) in self._handlers.items():
42
61
  if topic not in self._consumer_tasks:
43
- self._consumer_tasks[topic] = asyncio.create_task(self._start_consumer(topic, handler, data_type, group_id))
62
+ self._consumer_tasks[topic] = asyncio.create_task(
63
+ self._start_consumer(topic, handler, data_type, group_id)
64
+ )
44
65
 
45
66
  self._running = True
46
67
  while self._running:
47
68
  await asyncio.sleep(1)
48
69
 
49
- async def _start_consumer(self, topic: str, handler: Callable, data_type: type, group_id: str):
70
+ async def _start_consumer(
71
+ self, topic: str, handler: Callable, data_type: type, group_id: str
72
+ ):
50
73
  consumer = AIOKafkaConsumer(
51
74
  topic,
52
75
  bootstrap_servers=self.bootstrap_servers,
@@ -65,16 +88,62 @@ class KafkaApp:
65
88
  finally:
66
89
  await consumer.stop()
67
90
 
68
- async def _dispatch_message(self, handler: Callable, msg: ConsumerRecord, data_type: type):
91
+ async def _dispatch_message(
92
+ self, handler: Callable, msg: ConsumerRecord, data_type: type
93
+ ):
94
+ header_carrier: dict[str, str] = {}
95
+ for key, value in msg.headers or []:
96
+ try:
97
+ header_carrier[key] = (
98
+ value.decode("utf-8")
99
+ if isinstance(value, (bytes, bytearray))
100
+ else str(value)
101
+ )
102
+ except Exception:
103
+ header_carrier[key] = ""
104
+
69
105
  try:
70
- data = data_type()
71
- data.ParseFromString(msg.value)
72
- except Exception as e:
73
- print("Error:", e)
74
- data = data_type(**json.loads(msg.value.decode("utf-8")))
75
- result = handler(data)
76
- if inspect.iscoroutine(result):
77
- await result
106
+ parent_context = extract(header_carrier)
107
+ except Exception:
108
+ parent_context = otel_context_api.get_current()
109
+ if parent_context is None:
110
+ parent_context = otel_context_api.get_current()
111
+
112
+ span_name = f"Kafka consume {msg.topic}"
113
+ with self._tracer.start_as_current_span(
114
+ span_name,
115
+ context=parent_context,
116
+ kind=SpanKind.CONSUMER,
117
+ ) as span:
118
+ span.set_attribute("messaging.system", "kafka")
119
+ span.set_attribute("messaging.destination", msg.topic)
120
+ span.set_attribute("messaging.kafka.partition", msg.partition)
121
+ span.set_attribute("messaging.kafka.offset", msg.offset)
122
+
123
+ execution_context = ExecutionContext(
124
+ trace_context=otel_context_api.get_current(),
125
+ span=span,
126
+ logger=logger.bind(topic=msg.topic),
127
+ metadata={
128
+ "entrypoint": "kafka",
129
+ "topic": msg.topic,
130
+ "partition": msg.partition,
131
+ },
132
+ )
133
+ token = set_current_context(execution_context)
134
+
135
+ try:
136
+ try:
137
+ data = data_type()
138
+ data.ParseFromString(msg.value)
139
+ except Exception as e:
140
+ print("Error:", e)
141
+ data = data_type(**json.loads(msg.value.decode("utf-8")))
142
+ result = handler(data)
143
+ if inspect.iscoroutine(result):
144
+ await result
145
+ finally:
146
+ reset_current_context(token)
78
147
 
79
148
  async def _start_producer(self):
80
149
  if self._producer is None:
@@ -94,14 +163,31 @@ class KafkaApp:
94
163
  async def send_message(self, topic: str, data_type: type, data: Any) -> None:
95
164
  if not self._running:
96
165
  raise RuntimeError("KafkaApp is not running. Call start() first.")
97
-
166
+
98
167
  if self._producer is None:
99
168
  raise RuntimeError("Kafka producer is not initialized.")
100
-
169
+
170
+ headers_carrier: dict[str, str] = {}
171
+ execution_context = get_current_context()
172
+ try:
173
+ if execution_context and execution_context.trace_context is not None:
174
+ inject(headers_carrier, context=execution_context.trace_context)
175
+ else:
176
+ inject(headers_carrier)
177
+ except Exception as e:
178
+ logger.warning(f"inject trace context failed: {e}")
179
+
101
180
  try:
102
- await self._producer.send_and_wait(topic, data)
181
+ await self._producer.send_and_wait(
182
+ topic,
183
+ data,
184
+ headers=[
185
+ (key, str(value).encode("utf-8"))
186
+ for key, value in headers_carrier.items()
187
+ ],
188
+ )
103
189
  logger.info(f"✅ 已发送消息到 topic '{topic}', type: {data_type}")
104
-
190
+
105
191
  except Exception as e:
106
192
  logger.error(f"❌ 发送消息到 topic '{topic}' 失败: {e}")
107
193
  raise
@@ -109,18 +195,20 @@ class KafkaApp:
109
195
  async def stop(self):
110
196
  logger.info("Stopping KafkaApp ...")
111
197
  self._running = False
112
-
198
+
113
199
  for t in list(self._consumer_tasks.values()):
114
200
  t.cancel()
115
201
  await asyncio.sleep(0.1)
116
202
  self._consumer_tasks.clear()
117
-
203
+
118
204
  await self._stop_producer()
119
-
205
+
120
206
  logger.info("✅ KafkaApp stopped")
121
207
 
208
+
122
209
  kafka_app = KafkaApp()
123
210
 
211
+
124
212
  async def start_kafka_server(bootstrap_servers: str):
125
213
  kafka_app.set_bootstrap_servers(bootstrap_servers)
126
- await kafka_app.start()
214
+ await kafka_app.start()
@@ -0,0 +1,57 @@
1
+ from typing import Type
2
+
3
+ from fastapi import APIRouter
4
+ from omegaconf import OmegaConf
5
+
6
+ from service_forge.model.meta_api.schema import NodeTypeSchema, NodeInputPortSchema, NodeOutputPortSchema
7
+ from service_forge.sft.config.sf_metadata import load_metadata
8
+ from service_forge.workflow.node import Node, node_register
9
+ from service_forge.workflow.trigger import Trigger
10
+
11
+ def _node_class_to_schema(node_class: Type[Node]) -> NodeTypeSchema:
12
+ """将Node子类转化为NodeTypeSchema"""
13
+ _input_ports = {}
14
+ for port in node_class.DEFAULT_INPUT_PORTS:
15
+ _input_ports[port.name] = NodeInputPortSchema(
16
+ name=port.name,
17
+ type=port.type.__name__,
18
+ default_value=port.default
19
+ )
20
+
21
+ _output_ports = {}
22
+ for port in node_class.DEFAULT_OUTPUT_PORTS:
23
+ _output_ports[port.name] = NodeOutputPortSchema(
24
+ name=port.name,
25
+ type=port.type.__name__
26
+ )
27
+
28
+ return NodeTypeSchema(
29
+ name = node_class.__name__,
30
+ inputs=_input_ports,
31
+ outputs=_output_ports,
32
+ is_trigger=issubclass(node_class, Trigger)
33
+ )
34
+
35
+
36
+ def create_schema_router() -> APIRouter:
37
+ router = APIRouter(prefix='/schema')
38
+
39
+ @router.get('/nodes', response_model=list[NodeTypeSchema])
40
+ def get_node_schema():
41
+ schemas = []
42
+ for k, v in node_register.items.items():
43
+ if v is not Trigger:
44
+ schemas.append(_node_class_to_schema(v))
45
+ return schemas
46
+
47
+ return router
48
+
49
+ def create_meta_api_router() -> APIRouter:
50
+ router = APIRouter(tags=['meta-api'], prefix='/sdk/meta-api')
51
+ router.include_router(create_schema_router())
52
+ return router
53
+
54
+ metadata = load_metadata('sf-meta.yaml')
55
+ service_config = OmegaConf.load(metadata.service_config)
56
+
57
+ meta_api_router = create_meta_api_router()
@@ -1,10 +1,12 @@
1
+ import json
1
2
  import os
2
3
  import uuid
3
4
  import tempfile
4
5
  from fastapi import APIRouter, HTTPException, UploadFile, File, Form
6
+ from fastapi.params import Body, Path
5
7
  from fastapi.responses import JSONResponse
6
8
  from loguru import logger
7
- from typing import Optional, TYPE_CHECKING
9
+ from typing import Optional, TYPE_CHECKING, Dict, Any
8
10
  from pydantic import BaseModel
9
11
  from omegaconf import OmegaConf
10
12
  from service_forge.current_service import get_service
@@ -18,18 +20,32 @@ class WorkflowStatusResponse(BaseModel):
18
20
  workflows: list[dict]
19
21
 
20
22
  class WorkflowActionResponse(BaseModel):
21
- workflow_id: str
23
+ workflow_id: Optional[str] = None
22
24
  success: bool
23
25
  message: str
26
+ task_id: Optional[str] = None
24
27
 
25
28
  @service_router.get("/status", response_model=WorkflowStatusResponse)
26
29
  async def get_service_status():
27
30
  service = get_service()
28
31
  if service is None:
29
32
  raise HTTPException(status_code=503, detail="Service not initialized")
30
-
33
+ # 排除调试版本
34
+ try:
35
+ status = service.get_service_status(exclude_debug=True)
36
+ return status
37
+ except Exception as e:
38
+ logger.error(f"Error getting service status: {e}")
39
+ raise HTTPException(status_code=500, detail=str(e))
40
+
41
+ @service_router.get("/workflow/{workflow_id}/status", response_model=dict)
42
+ def get_workflow_data(workflow_id: str):
43
+ service = get_service()
44
+ if service is None:
45
+ raise HTTPException(status_code=503, detail="Service not initialized")
46
+
31
47
  try:
32
- status = service.get_service_status()
48
+ status = service.get_workflow_status(workflow_id)
33
49
  return status
34
50
  except Exception as e:
35
51
  logger.error(f"Error getting service status: {e}")
@@ -42,7 +58,7 @@ async def start_workflow(workflow_id: str):
42
58
  raise HTTPException(status_code=503, detail="Service not initialized")
43
59
 
44
60
  try:
45
- success = service.start_workflow_by_id(uuid.UUID(workflow_id))
61
+ success = service.start_workflow_by_id(workflow_id)
46
62
  if success:
47
63
  return WorkflowActionResponse(success=True, message=f"Workflow {workflow_id} started successfully")
48
64
  else:
@@ -58,7 +74,7 @@ async def stop_workflow(workflow_id: str):
58
74
  raise HTTPException(status_code=503, detail="Service not initialized")
59
75
 
60
76
  try:
61
- success = await service.stop_workflow_by_id(uuid.UUID(workflow_id))
77
+ success = await service.stop_workflow_by_id(workflow_id)
62
78
  if success:
63
79
  return WorkflowActionResponse(success=True, message=f"Workflow {workflow_id} stopped successfully")
64
80
  else:
@@ -67,6 +83,26 @@ async def stop_workflow(workflow_id: str):
67
83
  logger.error(f"Error stopping workflow {workflow_id}: {e}")
68
84
  raise HTTPException(status_code=500, detail=str(e))
69
85
 
86
+ class TriggerWorkflowRequest(BaseModel):
87
+ kwargs: Optional[Dict[str, Any]] = {}
88
+
89
+ @service_router.post("/workflow/{workflow_id}/trigger", response_model=WorkflowActionResponse)
90
+ async def trigger_workflow(workflow_id: str = Path(...), request_body: TriggerWorkflowRequest = Body(...)):
91
+ service = get_service()
92
+ if service is None:
93
+ raise HTTPException(status_code=503, detail="Service not initialized")
94
+
95
+ try:
96
+ task_id = service.trigger_workflow_by_id(workflow_id, "", None, **request_body.kwargs) # Trigger Name实际上没有使用
97
+ if task_id is not None:
98
+ return WorkflowActionResponse(workflow_id=workflow_id, task_id=str(task_id), success=True, message=f"Workflow {workflow_id} triggered successfully with task_id {task_id}")
99
+ else:
100
+ return WorkflowActionResponse(workflow_id=workflow_id, success=False, message=f"Failed to trigger workflow {workflow_id}")
101
+ except Exception as e:
102
+ logger.error(f"Error triggering workflow {workflow_id}: {e}")
103
+ raise HTTPException(status_code=500, detail=str(e))
104
+
105
+
70
106
  @service_router.post("/workflow/upload", response_model=WorkflowActionResponse)
71
107
  async def upload_workflow_config(
72
108
  file: Optional[UploadFile] = File(None),