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
@@ -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
- from fastapi import HTTPException, Request
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,12 +44,97 @@ 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
 
53
+ def validate_auth_from_headers(
54
+ headers: dict,
55
+ origin: str | None,
56
+ scheme: str,
57
+ host: str,
58
+ trusted_domain: str = "ring.shiweinan.com",
59
+ ) -> tuple[str | None, str | None]:
60
+ """
61
+ Validate authentication from headers and return user_id and token.
62
+
63
+ Args:
64
+ headers: Dictionary of headers (can be from Request or WebSocket)
65
+ origin: Origin header value
66
+ scheme: URL scheme (http/https/ws/wss)
67
+ host: Host header value
68
+ trusted_domain: Trusted domain for origin validation
69
+
70
+ Returns:
71
+ tuple: (user_id, auth_token) - user_id can be None if not authenticated and not same origin
72
+ """
73
+ is_same_origin = False
74
+
75
+ logger.debug(f"origin {origin}, host:{host}")
76
+
77
+ if origin and host:
78
+ try:
79
+ parsed_origin = urlparse(origin)
80
+ parsed_host = urlparse(f"{scheme}://{host}")
81
+ is_same_origin = (
82
+ parsed_origin.hostname == parsed_host.hostname
83
+ and parsed_origin.port == parsed_host.port
84
+ and is_trusted_origin(parsed_origin.hostname, parsed_host.hostname, trusted_domain)
85
+ )
86
+ except Exception:
87
+ pass
88
+
89
+ user_id = headers.get("X-User-ID")
90
+ token = headers.get("X-User-Token")
91
+
92
+ if not is_same_origin:
93
+ # For cross-origin requests, user_id is required
94
+ if not user_id:
95
+ return None, None
96
+ return user_id, token
97
+ else:
98
+ # For same-origin requests, user_id defaults to "0" if not provided
99
+ return user_id if user_id else "0", token
100
+
101
+
102
+ async def authenticate_websocket(
103
+ websocket: WebSocket,
104
+ trusted_domain: str = "ring.shiweinan.com",
105
+ ) -> None:
106
+ """
107
+ Authenticate WebSocket connection and set user_id and auth_token in websocket.state.
108
+
109
+ Args:
110
+ websocket: WebSocket instance
111
+ trusted_domain: Trusted domain for origin validation
112
+
113
+ Raises:
114
+ WebSocketException: If authentication fails
115
+ """
116
+ if not websocket.url.path.startswith("/api"):
117
+ return
118
+
119
+ origin = websocket.headers.get("origin") or websocket.headers.get("referer")
120
+ scheme = websocket.url.scheme
121
+ host = websocket.headers.get("host", "")
122
+
123
+ user_id, token = validate_auth_from_headers(
124
+ websocket.headers,
125
+ origin,
126
+ scheme,
127
+ host,
128
+ trusted_domain,
129
+ )
130
+
131
+ if user_id is None:
132
+ raise WebSocketException(code=1008, reason="Unauthorized")
133
+
134
+ websocket.state.user_id = user_id
135
+ websocket.state.auth_token = token
136
+
137
+
39
138
  def create_app(
40
139
  app: FastAPI | None = None,
41
140
  routers: list[APIRouter] | None = None,
@@ -46,14 +145,14 @@ def create_app(
46
145
  ) -> FastAPI:
47
146
  """
48
147
  Create or configure a FastAPI app with common middleware and configuration.
49
-
148
+
50
149
  Args:
51
150
  app: Optional existing FastAPI instance. If None, creates a new one.
52
151
  routers: List of APIRouter instances to include
53
152
  cors_origins: List of allowed CORS origins. Defaults to ["*"]
54
153
  enable_auth_middleware: Whether to enable authentication middleware
55
154
  trusted_domain: Trusted domain for origin validation
56
-
155
+
57
156
  Returns:
58
157
  FastAPI: Configured FastAPI application instance
59
158
  """
@@ -63,7 +162,7 @@ def create_app(
63
162
  # Configure CORS middleware
64
163
  if cors_origins is None:
65
164
  cors_origins = ["*"]
66
-
165
+
67
166
  app.add_middleware(
68
167
  CORSMiddleware,
69
168
  allow_origins=cors_origins,
@@ -72,64 +171,111 @@ def create_app(
72
171
  allow_headers=["*"],
73
172
  )
74
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
+
75
217
  # Include routers if provided
76
218
  if routers:
77
219
  for router in routers:
78
220
  app.include_router(router)
79
221
 
222
+ # Store auth configuration in app.state for WebSocket endpoints to access
223
+ app.state.enable_auth_middleware = enable_auth_middleware
224
+ app.state.trusted_domain = trusted_domain
225
+
80
226
  # Always include WebSocket router
81
227
  app.include_router(websocket_router)
82
228
 
83
229
  # Include Feedback router
84
230
  app.include_router(feedback_router)
85
231
 
86
- # Always include Service router
232
+ # Always include Service router, Meta API Router, Trace Router, and Static Files
87
233
  app.include_router(service_router)
88
-
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
+
89
245
  # Add authentication middleware if enabled
90
- if enable_auth_middleware:
91
- @app.middleware("http")
92
- async def auth_middleware(request: Request, call_next):
93
- """
94
- Authentication middleware for API routes.
95
-
96
- Validates user authentication for /api routes with origin-based
97
- trust verification and X-User-ID header validation.
98
- """
99
- if request.url.path.startswith("/api"):
100
- origin = request.headers.get("origin") or request.headers.get("referer")
101
- scheme = request.url.scheme
102
- host = request.headers.get("host", "")
103
- is_same_origin = False
104
-
105
- logger.debug(f"origin {origin}, host:{host}")
106
-
107
- if origin and host:
108
- try:
109
- parsed_origin = urlparse(origin)
110
- parsed_host = urlparse(f"{scheme}://{host}")
111
- is_same_origin = (
112
- parsed_origin.hostname == parsed_host.hostname
113
- and parsed_origin.port == parsed_host.port
114
- and is_trusted_origin(parsed_origin.hostname, parsed_host.hostname, trusted_domain)
115
- )
116
- except Exception:
117
- pass # If parsing fails, continue with default behavior
118
- if not is_same_origin:
119
- headers = request.headers
120
- user_id = headers.get("X-User-ID")
121
- if not user_id:
122
- raise HTTPException(status_code=401, detail="Unauthorized")
123
-
124
- request.state.user_id = user_id
125
- else:
126
- # Same-origin requests can skip auth, but still set default user_id
127
- request.state.user_id = "0" # Can be None or default value as needed
128
-
129
- return await call_next(request)
130
-
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
+
131
276
  return app
132
277
 
278
+
133
279
  async def start_fastapi_server(host: str, port: int):
134
280
  try:
135
281
  config = uvicorn.Config(
@@ -147,6 +293,10 @@ async def start_fastapi_server(host: str, port: int):
147
293
 
148
294
  try:
149
295
  metadata = load_metadata("sf-meta.yaml")
150
- fastapi_app = create_app(enable_auth_middleware=False, 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)}")
151
300
  except Exception as e:
152
- fastapi_app = create_app(enable_auth_middleware=False, root_path=None)
301
+ logger.warning(f"Failed to load metadata, using default configuration: {e}")
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()