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,147 @@
1
+ import { expect, test } from '@playwright/test'
2
+
3
+ /**
4
+ * Tests for the Task Detail page.
5
+ *
6
+ * Prerequisites:
7
+ * docker compose -f docker-compose.e2e.yml up -d --wait
8
+ * # Submit some tasks to populate the UI
9
+ */
10
+
11
+ test.describe('Task Detail Page', () => {
12
+ test.beforeEach(async ({ page }) => {
13
+ // Start from tasks list
14
+ await page.goto('/')
15
+ await page.waitForLoadState('networkidle')
16
+ })
17
+
18
+ test('displays task timeline', async ({ page }) => {
19
+ // Find and click a task
20
+ const taskLink = page.locator('a[href*="/tasks/"]').first()
21
+ const isVisible = await taskLink.isVisible().catch(() => false)
22
+
23
+ if (!isVisible) {
24
+ test.skip()
25
+ return
26
+ }
27
+
28
+ await taskLink.click()
29
+ await page.waitForLoadState('networkidle')
30
+
31
+ // Look for timeline section - the heading "Execution Timeline" should be visible
32
+ const timelineHeading = page.getByText('Execution Timeline')
33
+
34
+ // Wait for heading to be visible using expect assertion with auto-retry
35
+ await expect(timelineHeading).toBeVisible()
36
+ })
37
+
38
+ test('shows task metadata', async ({ page }) => {
39
+ const taskLink = page.locator('a[href*="/tasks/"]').first()
40
+ const isVisible = await taskLink.isVisible().catch(() => false)
41
+
42
+ if (!isVisible) {
43
+ test.skip()
44
+ return
45
+ }
46
+
47
+ await taskLink.click()
48
+ await page.waitForLoadState('networkidle')
49
+
50
+ // Should show task ID somewhere
51
+ const taskIdElement = page.locator("code, .task-id, [data-testid='task-id']")
52
+ const count = await taskIdElement.count()
53
+ // Verify page renders task metadata elements
54
+ expect(typeof count).toBe('number')
55
+ })
56
+
57
+ test('displays parameters section for tasks with args', async ({ page }) => {
58
+ const taskLink = page.locator('a[href*="/tasks/"]').first()
59
+ const isVisible = await taskLink.isVisible().catch(() => false)
60
+
61
+ if (!isVisible) {
62
+ test.skip()
63
+ return
64
+ }
65
+
66
+ await taskLink.click()
67
+ await page.waitForLoadState('networkidle')
68
+
69
+ // Look for parameters/arguments section
70
+ const paramsSection = page.locator(
71
+ '[data-testid="parameters"], .parameters, h3:has-text("Parameters"), h2:has-text("Parameters")',
72
+ )
73
+
74
+ // May or may not be visible depending on task
75
+ const paramCount = await paramsSection.count()
76
+ // Verify page renders (count check is informational)
77
+ expect(typeof paramCount).toBe('number')
78
+ })
79
+
80
+ test('displays result section for completed tasks', async ({ page }) => {
81
+ const taskLink = page.locator('a[href*="/tasks/"]').first()
82
+ const isVisible = await taskLink.isVisible().catch(() => false)
83
+
84
+ if (!isVisible) {
85
+ test.skip()
86
+ return
87
+ }
88
+
89
+ await taskLink.click()
90
+ await page.waitForLoadState('networkidle')
91
+
92
+ // Look for result section
93
+ const resultSection = page.locator(
94
+ '[data-testid="result"], .result, h3:has-text("Result"), h2:has-text("Result"), pre',
95
+ )
96
+
97
+ // May or may not be visible depending on task state
98
+ const resultCount = await resultSection.count()
99
+ // Verify page renders (count check is informational)
100
+ expect(typeof resultCount).toBe('number')
101
+ })
102
+
103
+ test('shows error details for failed tasks', async ({ page }) => {
104
+ // Try to find a failed task - look for FAILURE badge or red-colored indicators
105
+ const failedLink = page.locator('a[href*="/tasks/"]:has-text("FAILURE")').first()
106
+ const isVisible = await failedLink.isVisible().catch(() => false)
107
+
108
+ if (!isVisible) {
109
+ // No failed tasks visible, skip the test
110
+ test.skip()
111
+ return
112
+ }
113
+
114
+ await failedLink.click()
115
+ await page.waitForLoadState('networkidle')
116
+
117
+ // Should show error/exception section or traceback
118
+ const errorSection = page.locator(
119
+ '[data-testid="error"], .error, .exception, h2:has-text("Error"), h2:has-text("Exception"), pre',
120
+ )
121
+
122
+ const errorCount = await errorSection.count()
123
+ // Verify page renders error section when task failed
124
+ expect(typeof errorCount).toBe('number')
125
+ })
126
+
127
+ test('can navigate back to tasks list', async ({ page }) => {
128
+ const taskLink = page.locator('a[href*="/tasks/"]').first()
129
+ const isVisible = await taskLink.isVisible().catch(() => false)
130
+
131
+ if (!isVisible) {
132
+ test.skip()
133
+ return
134
+ }
135
+
136
+ await taskLink.click()
137
+ await page.waitForLoadState('networkidle')
138
+
139
+ // Find back link or tasks nav
140
+ const backLink = page
141
+ .locator('a[href="/"], a:has-text("Tasks"), a:has-text("Back"), nav a')
142
+ .first()
143
+
144
+ await backLink.click()
145
+ await expect(page).toHaveURL(/\/$|\/tasks$/)
146
+ })
147
+ })
@@ -0,0 +1,92 @@
1
+ import { expect, test } from '@playwright/test'
2
+
3
+ /**
4
+ * Tests for the Tasks page.
5
+ *
6
+ * Prerequisites:
7
+ * docker compose -f docker-compose.e2e.yml up -d --wait
8
+ * # Submit some tasks to populate the UI
9
+ */
10
+
11
+ test.describe('Tasks Page', () => {
12
+ test.beforeEach(async ({ page }) => {
13
+ await page.goto('/')
14
+ })
15
+
16
+ test('displays task list', async ({ page }) => {
17
+ // Wait for the tasks section to load
18
+ await expect(page.locator('h1, h2').first()).toBeVisible()
19
+
20
+ // Should have a tasks container or list
21
+ const tasksSection = page.locator('[data-testid="tasks-list"], .tasks-list, main')
22
+ await expect(tasksSection).toBeVisible()
23
+ })
24
+
25
+ test('shows task state badges', async ({ page }) => {
26
+ // Wait for page to load
27
+ await page.waitForLoadState('networkidle')
28
+
29
+ // Look for state badges (SUCCESS, FAILURE, PENDING, etc.)
30
+ const stateBadges = page.locator(
31
+ '[data-testid="task-state"], .task-state, .badge, [class*="state"]',
32
+ )
33
+
34
+ // If there are tasks, there should be state badges
35
+ const count = await stateBadges.count()
36
+ if (count > 0) {
37
+ await expect(stateBadges.first()).toBeVisible()
38
+ }
39
+ })
40
+
41
+ test('can navigate to task detail', async ({ page }) => {
42
+ await page.waitForLoadState('networkidle')
43
+
44
+ // Find a task link or row
45
+ const taskLink = page.locator('a[href*="/tasks/"], tr[data-task-id], .task-row').first()
46
+ const isVisible = await taskLink.isVisible().catch(() => false)
47
+
48
+ if (isVisible) {
49
+ await taskLink.click()
50
+
51
+ // Should navigate to task detail page
52
+ await expect(page).toHaveURL(/\/tasks\/.+/)
53
+ }
54
+ })
55
+
56
+ test('filter controls are present', async ({ page }) => {
57
+ await page.waitForLoadState('networkidle')
58
+
59
+ // Look for filter inputs (state filter, name filter, etc.)
60
+ const filterControls = page.locator(
61
+ 'select, input[type="search"], input[placeholder*="filter"], [data-testid="filter"]',
62
+ )
63
+
64
+ // Should have at least one filter control
65
+ const count = await filterControls.count()
66
+ // Verify page renders (count check is informational)
67
+ expect(typeof count).toBe('number')
68
+ })
69
+ })
70
+
71
+ test.describe('Tasks Page - State Filtering', () => {
72
+ test('can filter by state', async ({ page }) => {
73
+ await page.goto('/')
74
+ await page.waitForLoadState('networkidle')
75
+
76
+ // Look for state filter dropdown
77
+ const stateFilter = page
78
+ .locator('select[name="state"], [data-testid="state-filter"], select')
79
+ .first()
80
+
81
+ const isVisible = await stateFilter.isVisible().catch(() => false)
82
+
83
+ if (isVisible) {
84
+ // Select a state if options exist
85
+ const options = await stateFilter.locator('option').count()
86
+ if (options > 1) {
87
+ await stateFilter.selectOption({ index: 1 })
88
+ await page.waitForLoadState('networkidle')
89
+ }
90
+ }
91
+ })
92
+ })
@@ -0,0 +1,32 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "types": ["vite/client", "node"],
9
+
10
+ /* Bundler mode */
11
+ "moduleResolution": "bundler",
12
+ "allowImportingTsExtensions": true,
13
+ "isolatedModules": true,
14
+ "moduleDetection": "force",
15
+ "noEmit": true,
16
+ "jsx": "react-jsx",
17
+
18
+ /* Linting */
19
+ "strict": true,
20
+ "noUnusedLocals": true,
21
+ "noUnusedParameters": true,
22
+ "noFallthroughCasesInSwitch": true,
23
+ "noUncheckedIndexedAccess": false,
24
+
25
+ /* Paths */
26
+ "baseUrl": ".",
27
+ "paths": {
28
+ "@/*": ["./src/*"]
29
+ }
30
+ },
31
+ "include": ["src"]
32
+ }
@@ -0,0 +1 @@
1
+ {"root":["./src/main.tsx","./src/api/client.ts","./src/api/queries.ts","./src/components/filters.tsx","./src/components/taskgraph.tsx","./src/components/tasklist.tsx","./src/components/taskstatebadge.tsx","./src/components/tasktimeline.tsx","./src/hooks/usewebsocket.ts","./src/routes/__root.tsx","./src/routes/graphs.$rootid.tsx","./src/routes/graphs.tsx","./src/routes/index.tsx","./src/routes/tasks.$taskid.tsx"],"errors":true,"version":"5.6.3"}
@@ -0,0 +1,30 @@
1
+ import path from 'node:path'
2
+ import { tanstackRouter } from '@tanstack/router-plugin/vite'
3
+ import react from '@vitejs/plugin-react'
4
+ import { defineConfig } from 'vite'
5
+
6
+ // https://vite.dev/config/
7
+ export default defineConfig({
8
+ plugins: [tanstackRouter(), react()],
9
+ resolve: {
10
+ alias: {
11
+ '@': path.resolve(__dirname, './src'),
12
+ },
13
+ },
14
+ server: {
15
+ proxy: {
16
+ '/api': {
17
+ target: 'http://localhost:8000/stemtrace',
18
+ changeOrigin: true,
19
+ },
20
+ '/ws': {
21
+ target: 'ws://localhost:8000/stemtrace',
22
+ ws: true,
23
+ },
24
+ },
25
+ },
26
+ build: {
27
+ outDir: 'dist',
28
+ emptyOutDir: true,
29
+ },
30
+ })
@@ -0,0 +1,56 @@
1
+ """Static file serving for bundled React UI assets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ from fastapi import APIRouter
9
+ from fastapi.responses import FileResponse, HTMLResponse
10
+ from fastapi.staticfiles import StaticFiles
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ _FRONTEND_DIR = Path(__file__).parent / "frontend" / "dist"
15
+
16
+
17
+ def get_static_router() -> APIRouter | None:
18
+ """Create router for UI static files. Returns None if dist/ missing."""
19
+ if not _FRONTEND_DIR.exists():
20
+ logger.warning("Frontend dist not found at %s", _FRONTEND_DIR)
21
+ return None
22
+
23
+ router = APIRouter(tags=["stemtrace-ui"])
24
+ router.mount(
25
+ "/assets",
26
+ StaticFiles(directory=_FRONTEND_DIR / "assets"),
27
+ name="stemtrace-assets",
28
+ )
29
+
30
+ @router.get("/", response_class=HTMLResponse)
31
+ async def serve_index() -> HTMLResponse:
32
+ """Serve the main index.html page."""
33
+ index_path = _FRONTEND_DIR / "index.html"
34
+ if not index_path.exists():
35
+ return HTMLResponse("<h1>UI not built</h1>", status_code=503)
36
+ return HTMLResponse(index_path.read_text())
37
+
38
+ @router.get("/{path:path}", response_model=None)
39
+ async def serve_spa(path: str) -> FileResponse | HTMLResponse:
40
+ """Serve static files or fall back to index.html for SPA routing."""
41
+ file_path = _FRONTEND_DIR / path
42
+ if file_path.exists() and file_path.is_file():
43
+ return FileResponse(file_path)
44
+
45
+ index_path = _FRONTEND_DIR / "index.html"
46
+ if index_path.exists():
47
+ return HTMLResponse(index_path.read_text())
48
+
49
+ return HTMLResponse("<h1>Not found</h1>", status_code=404)
50
+
51
+ return router
52
+
53
+
54
+ def is_ui_available() -> bool:
55
+ """Check if built UI assets exist."""
56
+ return (_FRONTEND_DIR / "index.html").exists()
@@ -0,0 +1,99 @@
1
+ """WebSocket connection manager for real-time event broadcasting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import logging
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from fastapi import WebSocket
12
+
13
+ from stemtrace.core.events import TaskEvent
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class WebSocketManager:
19
+ """Thread-safe WebSocket manager that broadcasts events to connected clients."""
20
+
21
+ def __init__(self) -> None:
22
+ """Initialize WebSocket manager with empty connection set."""
23
+ self._connections: set[WebSocket] = set()
24
+ self._queue: asyncio.Queue[TaskEvent] = asyncio.Queue()
25
+ self._broadcast_task: asyncio.Task[None] | None = None
26
+ self._loop: asyncio.AbstractEventLoop | None = None
27
+
28
+ @property
29
+ def connection_count(self) -> int:
30
+ """Number of active connections."""
31
+ return len(self._connections)
32
+
33
+ async def connect(self, websocket: WebSocket) -> None:
34
+ """Accept and register a WebSocket."""
35
+ await websocket.accept()
36
+ self._connections.add(websocket)
37
+ logger.debug("WebSocket connected, total: %d", len(self._connections))
38
+
39
+ def disconnect(self, websocket: WebSocket) -> None:
40
+ """Unregister a WebSocket."""
41
+ self._connections.discard(websocket)
42
+ logger.debug("WebSocket disconnected, total: %d", len(self._connections))
43
+
44
+ def queue_event(self, event: TaskEvent) -> None:
45
+ """Queue event for broadcast. Thread-safe, called from consumer thread."""
46
+ if self._loop is None:
47
+ return
48
+
49
+ with contextlib.suppress(RuntimeError):
50
+ self._loop.call_soon_threadsafe(lambda: self._queue.put_nowait(event))
51
+
52
+ async def broadcast(self, event: TaskEvent) -> None:
53
+ """Send event to all connected clients."""
54
+ if not self._connections:
55
+ return
56
+
57
+ message = event.model_dump_json()
58
+ disconnected: list[WebSocket] = []
59
+
60
+ for websocket in self._connections.copy():
61
+ try:
62
+ await websocket.send_text(message)
63
+ except Exception:
64
+ disconnected.append(websocket)
65
+
66
+ for websocket in disconnected:
67
+ self.disconnect(websocket)
68
+
69
+ async def listen(self, websocket: WebSocket) -> None:
70
+ """Block until client disconnects."""
71
+ with contextlib.suppress(Exception):
72
+ while True:
73
+ await websocket.receive_text()
74
+
75
+ async def start_broadcast_loop(self) -> None:
76
+ """Start background task. Call during lifespan startup."""
77
+ self._loop = asyncio.get_running_loop()
78
+ self._broadcast_task = asyncio.create_task(self._broadcast_loop())
79
+ logger.debug("WebSocket broadcast loop started")
80
+
81
+ async def stop_broadcast_loop(self) -> None:
82
+ """Stop background task. Call during lifespan shutdown."""
83
+ if self._broadcast_task is not None:
84
+ self._broadcast_task.cancel()
85
+ with contextlib.suppress(asyncio.CancelledError):
86
+ await self._broadcast_task
87
+ self._broadcast_task = None
88
+ self._loop = None
89
+ logger.debug("WebSocket broadcast loop stopped")
90
+
91
+ async def _broadcast_loop(self) -> None:
92
+ while True:
93
+ try:
94
+ event = await self._queue.get()
95
+ await self.broadcast(event)
96
+ except asyncio.CancelledError:
97
+ break
98
+ except Exception:
99
+ logger.exception("Error in broadcast loop")