stemtrace 0.1.0__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.
Files changed (72) hide show
  1. stemtrace/__init__.py +173 -0
  2. stemtrace/core/__init__.py +15 -0
  3. stemtrace/core/events.py +67 -0
  4. stemtrace/core/exceptions.py +25 -0
  5. stemtrace/core/graph.py +348 -0
  6. stemtrace/core/ports.py +42 -0
  7. stemtrace/library/__init__.py +1 -0
  8. stemtrace/library/bootsteps.py +169 -0
  9. stemtrace/library/config.py +55 -0
  10. stemtrace/library/scrubbing.py +195 -0
  11. stemtrace/library/signals.py +434 -0
  12. stemtrace/library/transports/__init__.py +36 -0
  13. stemtrace/library/transports/memory.py +33 -0
  14. stemtrace/library/transports/redis.py +92 -0
  15. stemtrace/py.typed +0 -0
  16. stemtrace/server/__init__.py +24 -0
  17. stemtrace/server/__main__.py +136 -0
  18. stemtrace/server/api/__init__.py +29 -0
  19. stemtrace/server/api/routes.py +270 -0
  20. stemtrace/server/api/schemas.py +136 -0
  21. stemtrace/server/api/websocket.py +28 -0
  22. stemtrace/server/consumer.py +133 -0
  23. stemtrace/server/fastapi/__init__.py +13 -0
  24. stemtrace/server/fastapi/auth.py +77 -0
  25. stemtrace/server/fastapi/extension.py +121 -0
  26. stemtrace/server/fastapi/router.py +42 -0
  27. stemtrace/server/store.py +260 -0
  28. stemtrace/server/ui/__init__.py +5 -0
  29. stemtrace/server/ui/frontend/biome.json +58 -0
  30. stemtrace/server/ui/frontend/dist/assets/index-CCs7iO1n.css +1 -0
  31. stemtrace/server/ui/frontend/dist/assets/index-Dao0_Q6x.js +14 -0
  32. stemtrace/server/ui/frontend/dist/index.html +17 -0
  33. stemtrace/server/ui/frontend/index.html +16 -0
  34. stemtrace/server/ui/frontend/package-lock.json +4049 -0
  35. stemtrace/server/ui/frontend/package.json +46 -0
  36. stemtrace/server/ui/frontend/playwright.config.ts +40 -0
  37. stemtrace/server/ui/frontend/postcss.config.js +5 -0
  38. stemtrace/server/ui/frontend/src/api/client.ts +173 -0
  39. stemtrace/server/ui/frontend/src/api/queries.ts +151 -0
  40. stemtrace/server/ui/frontend/src/components/DateRangePicker.tsx +244 -0
  41. stemtrace/server/ui/frontend/src/components/Filters.tsx +116 -0
  42. stemtrace/server/ui/frontend/src/components/TaskGraph.tsx +453 -0
  43. stemtrace/server/ui/frontend/src/components/TaskList.tsx +172 -0
  44. stemtrace/server/ui/frontend/src/components/TaskStateBadge.tsx +27 -0
  45. stemtrace/server/ui/frontend/src/components/TaskTimeline.tsx +144 -0
  46. stemtrace/server/ui/frontend/src/components/TimelineBar.tsx +224 -0
  47. stemtrace/server/ui/frontend/src/components/Tooltip.tsx +54 -0
  48. stemtrace/server/ui/frontend/src/hooks/WebSocketContext.tsx +143 -0
  49. stemtrace/server/ui/frontend/src/hooks/useInfiniteScroll.ts +56 -0
  50. stemtrace/server/ui/frontend/src/index.css +104 -0
  51. stemtrace/server/ui/frontend/src/main.tsx +44 -0
  52. stemtrace/server/ui/frontend/src/routes/__root.tsx +103 -0
  53. stemtrace/server/ui/frontend/src/routes/graph.$rootId.tsx +51 -0
  54. stemtrace/server/ui/frontend/src/routes/graphs.tsx +186 -0
  55. stemtrace/server/ui/frontend/src/routes/index.tsx +119 -0
  56. stemtrace/server/ui/frontend/src/routes/registry.tsx +211 -0
  57. stemtrace/server/ui/frontend/src/routes/tasks.$taskId.tsx +313 -0
  58. stemtrace/server/ui/frontend/src/utils/format.ts +38 -0
  59. stemtrace/server/ui/frontend/tests/graphs.spec.ts +485 -0
  60. stemtrace/server/ui/frontend/tests/registry.spec.ts +117 -0
  61. stemtrace/server/ui/frontend/tests/task-detail.spec.ts +147 -0
  62. stemtrace/server/ui/frontend/tests/tasks.spec.ts +92 -0
  63. stemtrace/server/ui/frontend/tsconfig.json +32 -0
  64. stemtrace/server/ui/frontend/tsconfig.tsbuildinfo +1 -0
  65. stemtrace/server/ui/frontend/vite.config.ts +30 -0
  66. stemtrace/server/ui/static.py +56 -0
  67. stemtrace/server/websocket.py +99 -0
  68. stemtrace-0.1.0.dist-info/METADATA +428 -0
  69. stemtrace-0.1.0.dist-info/RECORD +72 -0
  70. stemtrace-0.1.0.dist-info/WHEEL +4 -0
  71. stemtrace-0.1.0.dist-info/entry_points.txt +2 -0
  72. stemtrace-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,136 @@
1
+ """CLI entry point for stemtrace server."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ app = typer.Typer(
8
+ name="stemtrace",
9
+ help="Celery task flow visualizer",
10
+ no_args_is_help=True,
11
+ )
12
+
13
+
14
+ @app.command()
15
+ def server(
16
+ broker_url: Annotated[
17
+ str,
18
+ typer.Option(
19
+ "--broker-url",
20
+ "-b",
21
+ envvar="CELERY_FLOW_BROKER_URL",
22
+ help="Broker URL (default: redis://localhost:6379/0)",
23
+ ),
24
+ ] = "redis://localhost:6379/0",
25
+ host: Annotated[
26
+ str,
27
+ typer.Option("--host", "-h", help="Host to bind to"),
28
+ ] = "127.0.0.1",
29
+ port: Annotated[
30
+ int,
31
+ typer.Option("--port", "-p", help="Port to bind to"),
32
+ ] = 8000,
33
+ reload: Annotated[
34
+ bool,
35
+ typer.Option("--reload", help="Enable auto-reload (development)"),
36
+ ] = False,
37
+ ) -> None:
38
+ """Start the stemtrace web server with embedded consumer."""
39
+ import uvicorn
40
+ from fastapi import FastAPI
41
+
42
+ from stemtrace.server.fastapi.extension import StemtraceExtension
43
+
44
+ typer.echo(f"Starting stemtrace server on {host}:{port}")
45
+ typer.echo(f"Broker: {broker_url}")
46
+
47
+ from stemtrace.server.ui.static import get_static_router
48
+
49
+ # Create extension without UI (we'll serve UI at root separately)
50
+ extension = StemtraceExtension(broker_url=broker_url, serve_ui=False)
51
+ fastapi_app = FastAPI(
52
+ title="stemtrace",
53
+ lifespan=extension.lifespan,
54
+ )
55
+
56
+ # API and WebSocket at /stemtrace (frontend expects this path)
57
+ fastapi_app.include_router(extension.router, prefix="/stemtrace")
58
+
59
+ # UI at root for standalone CLI
60
+ ui_router = get_static_router()
61
+ if ui_router is not None:
62
+ fastapi_app.include_router(ui_router)
63
+
64
+ uvicorn.run(fastapi_app, host=host, port=port, reload=reload)
65
+
66
+
67
+ @app.command()
68
+ def consume(
69
+ broker_url: Annotated[
70
+ str,
71
+ typer.Option(
72
+ "--broker-url",
73
+ "-b",
74
+ envvar="CELERY_FLOW_BROKER_URL",
75
+ help="Broker URL (default: redis://localhost:6379/0)",
76
+ ),
77
+ ] = "redis://localhost:6379/0",
78
+ prefix: Annotated[
79
+ str,
80
+ typer.Option("--prefix", help="Stream key prefix"),
81
+ ] = "stemtrace",
82
+ ttl: Annotated[
83
+ int,
84
+ typer.Option("--ttl", help="Event TTL in seconds"),
85
+ ] = 86400,
86
+ ) -> None:
87
+ """Run the event consumer standalone (for external processing)."""
88
+ import signal
89
+ import sys
90
+
91
+ from stemtrace.server.consumer import EventConsumer
92
+ from stemtrace.server.store import GraphStore
93
+
94
+ typer.echo("Starting stemtrace consumer (standalone mode)")
95
+ typer.echo(f"Broker: {broker_url}")
96
+
97
+ store = GraphStore()
98
+ consumer = EventConsumer(broker_url, store, prefix=prefix, ttl=ttl)
99
+
100
+ def handle_signal(_signum: int, _frame: object) -> None:
101
+ """Handle shutdown signals gracefully."""
102
+ typer.echo("\nShutting down consumer...")
103
+ consumer.stop()
104
+ sys.exit(0)
105
+
106
+ signal.signal(signal.SIGINT, handle_signal)
107
+ signal.signal(signal.SIGTERM, handle_signal)
108
+
109
+ consumer.start()
110
+ typer.echo("Consumer running. Press Ctrl+C to stop.")
111
+
112
+ try:
113
+ signal.pause()
114
+ except AttributeError:
115
+ # Windows doesn't have signal.pause
116
+ import time
117
+
118
+ while consumer.is_running:
119
+ time.sleep(1)
120
+
121
+
122
+ @app.command()
123
+ def version() -> None:
124
+ """Show version information."""
125
+ from stemtrace import __version__
126
+
127
+ typer.echo(f"stemtrace {__version__}")
128
+
129
+
130
+ def main() -> None:
131
+ """CLI entry point."""
132
+ app()
133
+
134
+
135
+ if __name__ == "__main__":
136
+ main()
@@ -0,0 +1,29 @@
1
+ """API module - REST and WebSocket endpoints."""
2
+
3
+ from stemtrace.server.api.routes import create_api_router
4
+ from stemtrace.server.api.schemas import (
5
+ ErrorResponse,
6
+ GraphListResponse,
7
+ GraphNodeResponse,
8
+ GraphResponse,
9
+ HealthResponse,
10
+ TaskDetailResponse,
11
+ TaskEventResponse,
12
+ TaskListResponse,
13
+ TaskNodeResponse,
14
+ )
15
+ from stemtrace.server.api.websocket import create_websocket_router
16
+
17
+ __all__ = [
18
+ "ErrorResponse",
19
+ "GraphListResponse",
20
+ "GraphNodeResponse",
21
+ "GraphResponse",
22
+ "HealthResponse",
23
+ "TaskDetailResponse",
24
+ "TaskEventResponse",
25
+ "TaskListResponse",
26
+ "TaskNodeResponse",
27
+ "create_api_router",
28
+ "create_websocket_router",
29
+ ]
@@ -0,0 +1,270 @@
1
+ """REST API endpoints for stemtrace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ from datetime import datetime # noqa: TC003 (FastAPI needs this at runtime)
7
+ from typing import TYPE_CHECKING, Annotated
8
+
9
+ from fastapi import APIRouter, HTTPException, Query
10
+
11
+ from stemtrace.server.api.schemas import (
12
+ ErrorResponse,
13
+ GraphListResponse,
14
+ GraphNodeResponse,
15
+ GraphResponse,
16
+ HealthResponse,
17
+ RegisteredTaskResponse,
18
+ TaskDetailResponse,
19
+ TaskEventResponse,
20
+ TaskListResponse,
21
+ TaskNodeResponse,
22
+ TaskRegistryResponse,
23
+ )
24
+
25
+ if TYPE_CHECKING:
26
+ from stemtrace.core.events import TaskState
27
+ from stemtrace.core.graph import TaskNode
28
+ from stemtrace.server.consumer import AsyncEventConsumer
29
+ from stemtrace.server.store import GraphStore
30
+ from stemtrace.server.websocket import WebSocketManager
31
+
32
+
33
+ def _node_to_response(node: TaskNode) -> TaskNodeResponse:
34
+ first_seen = node.events[0].timestamp if node.events else None
35
+ last_updated = node.events[-1].timestamp if node.events else None
36
+
37
+ duration_ms = None
38
+ if first_seen and last_updated and first_seen != last_updated:
39
+ duration_ms = int((last_updated - first_seen).total_seconds() * 1000)
40
+
41
+ return TaskNodeResponse(
42
+ task_id=node.task_id,
43
+ name=node.name,
44
+ state=node.state,
45
+ node_type=node.node_type,
46
+ group_id=node.group_id,
47
+ chord_id=node.chord_id,
48
+ parent_id=node.parent_id,
49
+ children=node.children,
50
+ events=[TaskEventResponse.model_validate(e) for e in node.events],
51
+ first_seen=first_seen,
52
+ last_updated=last_updated,
53
+ duration_ms=duration_ms,
54
+ )
55
+
56
+
57
+ def _node_to_graph_response(
58
+ node: TaskNode,
59
+ all_nodes: dict[str, TaskNode] | None = None,
60
+ ) -> GraphNodeResponse:
61
+ first_seen = node.events[0].timestamp if node.events else None
62
+ last_updated = node.events[-1].timestamp if node.events else None
63
+
64
+ # For synthetic nodes (GROUP/CHORD), compute timing from children
65
+ if not node.events and node.children and all_nodes:
66
+ child_first_seen: list[datetime] = []
67
+ child_last_updated: list[datetime] = []
68
+ for child_id in node.children:
69
+ child = all_nodes.get(child_id)
70
+ if child and child.events:
71
+ child_first_seen.append(child.events[0].timestamp)
72
+ child_last_updated.append(child.events[-1].timestamp)
73
+ if child_first_seen:
74
+ first_seen = min(child_first_seen)
75
+ if child_last_updated:
76
+ last_updated = max(child_last_updated)
77
+
78
+ duration_ms = None
79
+ if first_seen and last_updated and first_seen != last_updated:
80
+ duration_ms = int((last_updated - first_seen).total_seconds() * 1000)
81
+
82
+ return GraphNodeResponse(
83
+ task_id=node.task_id,
84
+ name=node.name,
85
+ state=node.state,
86
+ node_type=node.node_type,
87
+ group_id=node.group_id,
88
+ chord_id=node.chord_id,
89
+ parent_id=node.parent_id,
90
+ children=node.children,
91
+ duration_ms=duration_ms,
92
+ first_seen=first_seen,
93
+ last_updated=last_updated,
94
+ )
95
+
96
+
97
+ def create_api_router(
98
+ store: GraphStore,
99
+ consumer: AsyncEventConsumer | None = None,
100
+ ws_manager: WebSocketManager | None = None,
101
+ ) -> APIRouter:
102
+ """Create REST API router with task and graph endpoints."""
103
+ router = APIRouter(prefix="/api", tags=["stemtrace"])
104
+
105
+ @router.get("/health", response_model=HealthResponse)
106
+ async def health() -> HealthResponse:
107
+ """Return server health status and connection counts."""
108
+ from stemtrace import __version__
109
+
110
+ return HealthResponse(
111
+ status="ok",
112
+ version=__version__,
113
+ consumer_running=consumer.is_running if consumer else False,
114
+ websocket_connections=ws_manager.connection_count if ws_manager else 0,
115
+ node_count=store.node_count,
116
+ )
117
+
118
+ @router.get(
119
+ "/tasks",
120
+ response_model=TaskListResponse,
121
+ responses={400: {"model": ErrorResponse}},
122
+ )
123
+ async def list_tasks(
124
+ limit: Annotated[int, Query(ge=1, le=500)] = 100,
125
+ offset: Annotated[int, Query(ge=0)] = 0,
126
+ state: Annotated[str | None, Query(description="Filter by task state")] = None,
127
+ name: Annotated[
128
+ str | None, Query(description="Filter by name substring")
129
+ ] = None,
130
+ from_date: Annotated[
131
+ datetime | None, Query(description="Filter by start date (ISO format)")
132
+ ] = None,
133
+ to_date: Annotated[
134
+ datetime | None, Query(description="Filter by end date (ISO format)")
135
+ ] = None,
136
+ ) -> TaskListResponse:
137
+ """List tasks with optional filtering by state, name, and date range."""
138
+ from stemtrace.core.events import TaskState as TS
139
+
140
+ task_state: TaskState | None = None
141
+ if state is not None:
142
+ with contextlib.suppress(ValueError):
143
+ task_state = TS(state)
144
+
145
+ nodes, total = store.get_nodes(
146
+ limit=limit,
147
+ offset=offset,
148
+ state=task_state,
149
+ name_contains=name,
150
+ from_date=from_date,
151
+ to_date=to_date,
152
+ )
153
+ return TaskListResponse(
154
+ tasks=[_node_to_response(n) for n in nodes],
155
+ total=total,
156
+ limit=limit,
157
+ offset=offset,
158
+ )
159
+
160
+ @router.get(
161
+ "/tasks/registry",
162
+ response_model=TaskRegistryResponse,
163
+ )
164
+ async def get_task_registry(
165
+ query: Annotated[
166
+ str | None, Query(description="Filter by task name substring")
167
+ ] = None,
168
+ ) -> TaskRegistryResponse:
169
+ """List all discovered task definitions."""
170
+ unique_names = store.get_unique_task_names()
171
+
172
+ tasks: list[RegisteredTaskResponse] = []
173
+ for name in sorted(unique_names):
174
+ if query and query.lower() not in name.lower():
175
+ continue
176
+
177
+ parts = name.rsplit(".", 1)
178
+ module = parts[0] if len(parts) > 1 else None
179
+
180
+ tasks.append(
181
+ RegisteredTaskResponse(
182
+ name=name,
183
+ module=module,
184
+ signature=None,
185
+ docstring=None,
186
+ bound=False,
187
+ )
188
+ )
189
+
190
+ return TaskRegistryResponse(tasks=tasks, total=len(tasks))
191
+
192
+ @router.get(
193
+ "/tasks/{task_id}",
194
+ response_model=TaskDetailResponse,
195
+ responses={404: {"model": ErrorResponse}},
196
+ )
197
+ async def get_task(task_id: str) -> TaskDetailResponse:
198
+ """Get detailed information for a specific task including children."""
199
+ node = store.get_node(task_id)
200
+ if node is None:
201
+ raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
202
+
203
+ children = store.get_children(task_id)
204
+ return TaskDetailResponse(
205
+ task=_node_to_response(node),
206
+ children=[_node_to_response(c) for c in children],
207
+ )
208
+
209
+ @router.get(
210
+ "/tasks/{task_id}/children",
211
+ response_model=list[TaskNodeResponse],
212
+ responses={404: {"model": ErrorResponse}},
213
+ )
214
+ async def get_task_children(task_id: str) -> list[TaskNodeResponse]:
215
+ """Get child tasks for a specific task."""
216
+ node = store.get_node(task_id)
217
+ if node is None:
218
+ raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
219
+
220
+ children = store.get_children(task_id)
221
+ return [_node_to_response(c) for c in children]
222
+
223
+ @router.get("/graphs", response_model=GraphListResponse)
224
+ async def list_graphs(
225
+ limit: Annotated[int, Query(ge=1, le=100)] = 50,
226
+ offset: Annotated[int, Query(ge=0)] = 0,
227
+ from_date: Annotated[
228
+ datetime | None, Query(description="Filter by start date (ISO format)")
229
+ ] = None,
230
+ to_date: Annotated[
231
+ datetime | None, Query(description="Filter by end date (ISO format)")
232
+ ] = None,
233
+ ) -> GraphListResponse:
234
+ """List task execution graphs (root tasks) with pagination and date filtering."""
235
+ roots, total = store.get_root_nodes(
236
+ limit=limit,
237
+ offset=offset,
238
+ from_date=from_date,
239
+ to_date=to_date,
240
+ )
241
+ # Build nodes dict for synthetic node timing computation
242
+ all_nodes: dict[str, TaskNode] = {}
243
+ for root in roots:
244
+ all_nodes[root.task_id] = root
245
+ for child in store.get_children(root.task_id):
246
+ all_nodes[child.task_id] = child
247
+ return GraphListResponse(
248
+ graphs=[_node_to_graph_response(r, all_nodes) for r in roots],
249
+ total=total,
250
+ limit=limit,
251
+ offset=offset,
252
+ )
253
+
254
+ @router.get(
255
+ "/graphs/{root_id}",
256
+ response_model=GraphResponse,
257
+ responses={404: {"model": ErrorResponse}},
258
+ )
259
+ async def get_graph(root_id: str) -> GraphResponse:
260
+ """Get complete task graph starting from a root task."""
261
+ graph = store.get_graph_from_root(root_id)
262
+ if not graph:
263
+ raise HTTPException(status_code=404, detail=f"Graph {root_id} not found")
264
+
265
+ return GraphResponse(
266
+ root_id=root_id,
267
+ nodes={tid: _node_to_graph_response(n, graph) for tid, n in graph.items()},
268
+ )
269
+
270
+ return router
@@ -0,0 +1,136 @@
1
+ """API response schemas for REST endpoints."""
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+ from stemtrace.core.events import TaskState
9
+ from stemtrace.core.graph import NodeType
10
+
11
+
12
+ class TaskEventResponse(BaseModel):
13
+ """Single task event with full details."""
14
+
15
+ model_config = ConfigDict(from_attributes=True)
16
+
17
+ task_id: str
18
+ name: str
19
+ state: TaskState
20
+ timestamp: datetime
21
+ parent_id: str | None = None
22
+ root_id: str | None = None
23
+ group_id: str | None = None
24
+ trace_id: str | None = None
25
+ retries: int = 0
26
+
27
+ # Enhanced event data
28
+ args: list[Any] | None = None
29
+ kwargs: dict[str, Any] | None = None
30
+ result: Any | None = None
31
+ exception: str | None = None
32
+ traceback: str | None = None
33
+
34
+
35
+ class TaskNodeResponse(BaseModel):
36
+ """Task node with events and timing info."""
37
+
38
+ model_config = ConfigDict(from_attributes=True)
39
+
40
+ task_id: str
41
+ name: str
42
+ state: TaskState
43
+ node_type: NodeType = NodeType.TASK
44
+ group_id: str | None = None
45
+ chord_id: str | None = None
46
+ parent_id: str | None = None
47
+ children: list[str] = Field(default_factory=list)
48
+ events: list[TaskEventResponse] = Field(default_factory=list)
49
+ first_seen: datetime | None = None
50
+ last_updated: datetime | None = None
51
+ duration_ms: int | None = None
52
+
53
+
54
+ class TaskListResponse(BaseModel):
55
+ """Paginated list of tasks."""
56
+
57
+ tasks: list[TaskNodeResponse]
58
+ total: int
59
+ limit: int
60
+ offset: int
61
+
62
+
63
+ class TaskDetailResponse(BaseModel):
64
+ """Task with its children."""
65
+
66
+ task: TaskNodeResponse
67
+ children: list[TaskNodeResponse] = Field(default_factory=list)
68
+
69
+
70
+ class GraphNodeResponse(BaseModel):
71
+ """Minimal node for graph visualization."""
72
+
73
+ task_id: str
74
+ name: str
75
+ state: TaskState
76
+ node_type: NodeType = NodeType.TASK
77
+ group_id: str | None = None
78
+ chord_id: str | None = None
79
+ parent_id: str | None = None
80
+ children: list[str] = Field(default_factory=list)
81
+ duration_ms: int | None = None
82
+ first_seen: datetime | None = None
83
+ last_updated: datetime | None = None
84
+
85
+
86
+ class GraphResponse(BaseModel):
87
+ """Full task graph from root."""
88
+
89
+ root_id: str
90
+ nodes: dict[str, GraphNodeResponse]
91
+
92
+
93
+ class GraphListResponse(BaseModel):
94
+ """Paginated list of root graphs."""
95
+
96
+ graphs: list[GraphNodeResponse]
97
+ total: int
98
+ limit: int
99
+ offset: int
100
+
101
+
102
+ class HealthResponse(BaseModel):
103
+ """Health check status."""
104
+
105
+ status: str = "ok"
106
+ version: str
107
+ consumer_running: bool = False
108
+ websocket_connections: int = 0
109
+ node_count: int = 0
110
+
111
+
112
+ class ErrorResponse(BaseModel):
113
+ """API error."""
114
+
115
+ detail: str
116
+ error_code: str | None = None
117
+
118
+
119
+ # Task Registry schemas
120
+
121
+
122
+ class RegisteredTaskResponse(BaseModel):
123
+ """A registered Celery task definition."""
124
+
125
+ name: str
126
+ signature: str | None = None
127
+ docstring: str | None = None
128
+ module: str | None = None
129
+ bound: bool = False
130
+
131
+
132
+ class TaskRegistryResponse(BaseModel):
133
+ """List of all registered tasks."""
134
+
135
+ tasks: list[RegisteredTaskResponse]
136
+ total: int
@@ -0,0 +1,28 @@
1
+ """WebSocket endpoint for real-time event streaming."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
8
+
9
+ if TYPE_CHECKING:
10
+ from stemtrace.server.websocket import WebSocketManager
11
+
12
+
13
+ def create_websocket_router(ws_manager: WebSocketManager) -> APIRouter:
14
+ """Create WebSocket router for real-time task events."""
15
+ router = APIRouter(tags=["stemtrace-websocket"])
16
+
17
+ @router.websocket("/ws")
18
+ async def websocket_endpoint(websocket: WebSocket) -> None:
19
+ """Handle WebSocket connection for real-time task events."""
20
+ await ws_manager.connect(websocket)
21
+ try:
22
+ await ws_manager.listen(websocket)
23
+ except WebSocketDisconnect:
24
+ pass
25
+ finally:
26
+ ws_manager.disconnect(websocket)
27
+
28
+ return router