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,133 @@
1
+ """Broker-agnostic event consumer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ from typing import TYPE_CHECKING
8
+
9
+ from stemtrace.library.transports import get_transport
10
+
11
+ if TYPE_CHECKING:
12
+ from stemtrace.core.ports import EventTransport
13
+ from stemtrace.server.store import GraphStore
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class EventConsumer:
19
+ """Background consumer that reads events and updates the GraphStore."""
20
+
21
+ def __init__(
22
+ self,
23
+ broker_url: str,
24
+ store: GraphStore,
25
+ *,
26
+ prefix: str = "stemtrace",
27
+ ttl: int = 86400,
28
+ ) -> None:
29
+ """Initialize consumer with broker URL and target store."""
30
+ self._broker_url = broker_url
31
+ self._store = store
32
+ self._prefix = prefix
33
+ self._ttl = ttl
34
+ self._transport: EventTransport | None = None
35
+ self._thread: threading.Thread | None = None
36
+ self._stop_event = threading.Event()
37
+
38
+ @property
39
+ def is_running(self) -> bool:
40
+ """Whether the consumer thread is alive."""
41
+ return self._thread is not None and self._thread.is_alive()
42
+
43
+ def start(self) -> None:
44
+ """Start consuming in background thread. Idempotent."""
45
+ if self._thread is not None:
46
+ return
47
+
48
+ self._stop_event.clear()
49
+ self._transport = get_transport(
50
+ self._broker_url, prefix=self._prefix, ttl=self._ttl
51
+ )
52
+ self._thread = threading.Thread(
53
+ target=self._consume_loop,
54
+ name="stemtrace-consumer",
55
+ daemon=True,
56
+ )
57
+ self._thread.start()
58
+ logger.info("Event consumer started")
59
+
60
+ def stop(self, timeout: float = 5.0) -> None:
61
+ """Stop the consumer gracefully."""
62
+ if self._thread is None:
63
+ return
64
+
65
+ self._stop_event.set()
66
+ self._thread.join(timeout=timeout)
67
+ if self._thread.is_alive():
68
+ logger.warning("Consumer thread did not stop gracefully")
69
+ self._thread = None
70
+ self._transport = None
71
+ logger.info("Event consumer stopped")
72
+
73
+ def _consume_loop(self) -> None:
74
+ if self._transport is None:
75
+ return
76
+
77
+ logger.debug("Consumer loop starting, reading from %s", self._broker_url)
78
+
79
+ try:
80
+ for event in self._transport.consume():
81
+ if self._stop_event.is_set():
82
+ break
83
+
84
+ try:
85
+ self._store.add_event(event)
86
+ logger.debug("Consumed event: %s (%s)", event.task_id, event.state)
87
+ except Exception:
88
+ logger.exception("Error processing event %s", event.task_id)
89
+ except Exception:
90
+ if not self._stop_event.is_set():
91
+ logger.exception("Consumer loop error")
92
+
93
+
94
+ class AsyncEventConsumer:
95
+ """Async context manager wrapper for EventConsumer."""
96
+
97
+ def __init__(
98
+ self,
99
+ broker_url: str,
100
+ store: GraphStore,
101
+ *,
102
+ prefix: str = "stemtrace",
103
+ ttl: int = 86400,
104
+ ) -> None:
105
+ """Initialize async consumer wrapper with broker URL and target store."""
106
+ self._consumer = EventConsumer(broker_url, store, prefix=prefix, ttl=ttl)
107
+
108
+ @property
109
+ def is_running(self) -> bool:
110
+ """Whether the consumer thread is alive."""
111
+ return self._consumer.is_running
112
+
113
+ async def __aenter__(self) -> AsyncEventConsumer:
114
+ """Start consumer on context enter."""
115
+ self._consumer.start()
116
+ return self
117
+
118
+ async def __aexit__(
119
+ self,
120
+ exc_type: type[BaseException] | None,
121
+ exc_val: BaseException | None,
122
+ exc_tb: object,
123
+ ) -> None:
124
+ """Stop consumer on context exit."""
125
+ self._consumer.stop()
126
+
127
+ def start(self) -> None:
128
+ """Start the consumer."""
129
+ self._consumer.start()
130
+
131
+ def stop(self, timeout: float = 5.0) -> None:
132
+ """Stop the consumer."""
133
+ self._consumer.stop(timeout=timeout)
@@ -0,0 +1,13 @@
1
+ """FastAPI integration module."""
2
+
3
+ from stemtrace.server.fastapi.auth import no_auth, require_api_key, require_basic_auth
4
+ from stemtrace.server.fastapi.extension import StemtraceExtension
5
+ from stemtrace.server.fastapi.router import create_router
6
+
7
+ __all__ = [
8
+ "StemtraceExtension",
9
+ "create_router",
10
+ "no_auth",
11
+ "require_api_key",
12
+ "require_basic_auth",
13
+ ]
@@ -0,0 +1,77 @@
1
+ """Authentication helpers for stemtrace API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ from typing import Annotated, Any
7
+
8
+ from fastapi import Depends, HTTPException, status
9
+ from fastapi.security import APIKeyHeader, HTTPBasic, HTTPBasicCredentials
10
+
11
+ _basic_auth = HTTPBasic(auto_error=False)
12
+
13
+
14
+ def require_basic_auth(username: str, password: str) -> Any:
15
+ """Create dependency requiring HTTP Basic auth."""
16
+
17
+ def _verify(
18
+ credentials: Annotated[HTTPBasicCredentials | None, Depends(_basic_auth)],
19
+ ) -> str:
20
+ if credentials is None:
21
+ raise HTTPException(
22
+ status_code=status.HTTP_401_UNAUTHORIZED,
23
+ detail="Authentication required",
24
+ headers={"WWW-Authenticate": "Basic"},
25
+ )
26
+
27
+ username_ok = secrets.compare_digest(
28
+ credentials.username.encode("utf-8"),
29
+ username.encode("utf-8"),
30
+ )
31
+ password_ok = secrets.compare_digest(
32
+ credentials.password.encode("utf-8"),
33
+ password.encode("utf-8"),
34
+ )
35
+
36
+ if not (username_ok and password_ok):
37
+ raise HTTPException(
38
+ status_code=status.HTTP_401_UNAUTHORIZED,
39
+ detail="Invalid credentials",
40
+ headers={"WWW-Authenticate": "Basic"},
41
+ )
42
+
43
+ return credentials.username
44
+
45
+ return Depends(_verify)
46
+
47
+
48
+ def require_api_key(api_key: str, *, header_name: str = "X-API-Key") -> Any:
49
+ """Create dependency requiring API key in header."""
50
+ key_header = APIKeyHeader(name=header_name, auto_error=False)
51
+
52
+ async def _verify(
53
+ provided_key: str | None = Depends(key_header),
54
+ ) -> str:
55
+ if provided_key is None:
56
+ raise HTTPException(
57
+ status_code=status.HTTP_401_UNAUTHORIZED,
58
+ detail="API key required",
59
+ )
60
+
61
+ if not secrets.compare_digest(
62
+ provided_key.encode("utf-8"),
63
+ api_key.encode("utf-8"),
64
+ ):
65
+ raise HTTPException(
66
+ status_code=status.HTTP_401_UNAUTHORIZED,
67
+ detail="Invalid API key",
68
+ )
69
+
70
+ return provided_key
71
+
72
+ return Depends(_verify)
73
+
74
+
75
+ def no_auth() -> Any:
76
+ """Dependency that allows all requests."""
77
+ return Depends(lambda: None)
@@ -0,0 +1,121 @@
1
+ """Full-featured FastAPI extension with embedded consumer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import asynccontextmanager
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from stemtrace.server.consumer import AsyncEventConsumer
9
+ from stemtrace.server.fastapi.router import create_router
10
+ from stemtrace.server.store import GraphStore
11
+ from stemtrace.server.ui.static import get_static_router
12
+ from stemtrace.server.websocket import WebSocketManager
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import AsyncIterator
16
+
17
+ from fastapi import APIRouter, FastAPI
18
+
19
+
20
+ class StemtraceExtension:
21
+ """Complete FastAPI integration: store, consumer, WebSocket, and router.
22
+
23
+ Example:
24
+ flow = StemtraceExtension(broker_url="redis://localhost:6379")
25
+ app = FastAPI(lifespan=flow.lifespan)
26
+ app.include_router(flow.router, prefix="/stemtrace")
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ broker_url: str,
32
+ *,
33
+ embedded_consumer: bool = True,
34
+ serve_ui: bool = True,
35
+ prefix: str = "stemtrace",
36
+ ttl: int = 86400,
37
+ max_nodes: int = 10000,
38
+ auth_dependency: Any = None,
39
+ ) -> None:
40
+ """Initialize extension with broker URL and optional configuration."""
41
+ self._broker_url = broker_url
42
+ self._embedded_consumer = embedded_consumer
43
+ self._serve_ui = serve_ui
44
+ self._prefix = prefix
45
+ self._ttl = ttl
46
+ self._auth_dependency = auth_dependency
47
+
48
+ self._store = GraphStore(max_nodes=max_nodes)
49
+ self._ws_manager = WebSocketManager()
50
+ self._consumer: AsyncEventConsumer | None = None
51
+
52
+ if embedded_consumer:
53
+ self._consumer = AsyncEventConsumer(
54
+ broker_url,
55
+ self._store,
56
+ prefix=prefix,
57
+ ttl=ttl,
58
+ )
59
+
60
+ self._store.add_listener(self._ws_manager.queue_event)
61
+
62
+ @property
63
+ def store(self) -> GraphStore:
64
+ """The in-memory graph store."""
65
+ return self._store
66
+
67
+ @property
68
+ def consumer(self) -> AsyncEventConsumer | None:
69
+ """Event consumer, or None if embedded_consumer=False."""
70
+ return self._consumer
71
+
72
+ @property
73
+ def ws_manager(self) -> WebSocketManager:
74
+ """The WebSocket connection manager."""
75
+ return self._ws_manager
76
+
77
+ @property
78
+ def router(self) -> APIRouter:
79
+ """Pre-configured router. Mount with app.include_router()."""
80
+ router = create_router(
81
+ store=self._store,
82
+ consumer=self._consumer,
83
+ ws_manager=self._ws_manager,
84
+ auth_dependency=self._auth_dependency,
85
+ )
86
+
87
+ if self._serve_ui:
88
+ ui_router = get_static_router()
89
+ if ui_router is not None:
90
+ router.include_router(ui_router)
91
+
92
+ return router
93
+
94
+ @asynccontextmanager
95
+ async def lifespan(self, app: FastAPI) -> AsyncIterator[None]: # noqa: ARG002
96
+ """FastAPI lifespan: starts consumer and WebSocket on startup."""
97
+ await self._ws_manager.start_broadcast_loop()
98
+ if self._consumer is not None:
99
+ self._consumer.start()
100
+
101
+ try:
102
+ yield
103
+ finally:
104
+ if self._consumer is not None:
105
+ self._consumer.stop()
106
+ await self._ws_manager.stop_broadcast_loop()
107
+
108
+ def compose_lifespan(self, other_lifespan: Any = None) -> Any:
109
+ """Compose with another lifespan context manager."""
110
+
111
+ @asynccontextmanager
112
+ async def combined(app: FastAPI) -> AsyncIterator[None]:
113
+ """Combined lifespan context manager."""
114
+ async with self.lifespan(app):
115
+ if other_lifespan is not None:
116
+ async with other_lifespan(app):
117
+ yield
118
+ else:
119
+ yield
120
+
121
+ return combined
@@ -0,0 +1,42 @@
1
+ """FastAPI router factory for stemtrace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from fastapi import APIRouter
8
+
9
+ from stemtrace.server.api.routes import create_api_router
10
+ from stemtrace.server.api.websocket import create_websocket_router
11
+ from stemtrace.server.store import GraphStore
12
+ from stemtrace.server.websocket import WebSocketManager
13
+
14
+ if TYPE_CHECKING:
15
+ from stemtrace.server.consumer import AsyncEventConsumer
16
+
17
+
18
+ def create_router(
19
+ store: GraphStore | None = None,
20
+ consumer: AsyncEventConsumer | None = None,
21
+ ws_manager: WebSocketManager | None = None,
22
+ *,
23
+ auth_dependency: Any = None,
24
+ ) -> APIRouter:
25
+ """Create API router. For embedded consumer, use StemtraceExtension."""
26
+ if store is None:
27
+ store = GraphStore()
28
+ if ws_manager is None:
29
+ ws_manager = WebSocketManager()
30
+
31
+ router = APIRouter()
32
+ api_router = create_api_router(store, consumer, ws_manager)
33
+ ws_router = create_websocket_router(ws_manager)
34
+
35
+ dependencies: list[Any] = []
36
+ if auth_dependency is not None:
37
+ dependencies.append(auth_dependency)
38
+
39
+ router.include_router(api_router, dependencies=dependencies)
40
+ router.include_router(ws_router)
41
+
42
+ return router
@@ -0,0 +1,260 @@
1
+ """Thread-safe in-memory graph store."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import threading
7
+ from datetime import datetime, timezone
8
+ from typing import TYPE_CHECKING
9
+
10
+ from stemtrace.core.graph import NodeType, TaskGraph, TaskNode
11
+
12
+
13
+ def _ensure_tz_aware(dt: datetime) -> datetime:
14
+ """Ensure datetime is timezone-aware (assume UTC if naive)."""
15
+ if dt.tzinfo is None:
16
+ return dt.replace(tzinfo=timezone.utc)
17
+ return dt
18
+
19
+
20
+ def _ensure_end_of_day(dt: datetime) -> datetime:
21
+ """Ensure to_date is end of day and timezone-aware.
22
+
23
+ When a date-only value (YYYY-MM-DD) is parsed, it becomes midnight.
24
+ For to_date filtering, we want end of day to include the full day.
25
+ """
26
+ if dt.tzinfo is None:
27
+ dt = dt.replace(tzinfo=timezone.utc)
28
+ # If time is midnight (date-only input), set to end of day
29
+ if dt.hour == 0 and dt.minute == 0 and dt.second == 0 and dt.microsecond == 0:
30
+ dt = dt.replace(hour=23, minute=59, second=59, microsecond=999999)
31
+ return dt
32
+
33
+
34
+ # Fallback for nodes with no events (synthetic nodes)
35
+ _MIN_DATETIME = datetime.min.replace(tzinfo=timezone.utc)
36
+
37
+
38
+ def _get_node_timestamp(node: TaskNode, graph: TaskGraph) -> datetime:
39
+ """Get the most recent timestamp for a node (including children for synthetic nodes)."""
40
+ if node.events:
41
+ return node.events[-1].timestamp
42
+
43
+ # For synthetic nodes (GROUP/CHORD), use the latest child timestamp
44
+ if node.node_type in (NodeType.GROUP, NodeType.CHORD) and node.children:
45
+ child_timestamps: list[datetime] = []
46
+ for child_id in node.children:
47
+ child = graph.get_node(child_id)
48
+ if child and child.events:
49
+ child_timestamps.append(child.events[-1].timestamp)
50
+ if child_timestamps:
51
+ return max(child_timestamps)
52
+
53
+ return _MIN_DATETIME
54
+
55
+
56
+ def _get_first_timestamp(node: TaskNode, graph: TaskGraph) -> datetime:
57
+ """Get the first timestamp for a node (including children for synthetic nodes)."""
58
+ if node.events:
59
+ return node.events[0].timestamp
60
+
61
+ # For synthetic nodes (GROUP/CHORD), use the earliest child timestamp
62
+ if node.node_type in (NodeType.GROUP, NodeType.CHORD) and node.children:
63
+ child_timestamps: list[datetime] = []
64
+ for child_id in node.children:
65
+ child = graph.get_node(child_id)
66
+ if child and child.events:
67
+ child_timestamps.append(child.events[0].timestamp)
68
+ if child_timestamps:
69
+ return min(child_timestamps)
70
+
71
+ return _MIN_DATETIME
72
+
73
+
74
+ if TYPE_CHECKING:
75
+ from collections.abc import Callable
76
+
77
+ from stemtrace.core.events import TaskEvent, TaskState
78
+
79
+
80
+ class GraphStore:
81
+ """Thread-safe in-memory store for TaskGraph with LRU eviction."""
82
+
83
+ def __init__(self, max_nodes: int = 10000) -> None:
84
+ """Initialize store with optional maximum node limit for LRU eviction."""
85
+ self._graph = TaskGraph()
86
+ self._lock = threading.RLock()
87
+ self._max_nodes = max_nodes
88
+ self._listeners: list[Callable[[TaskEvent], None]] = []
89
+
90
+ def add_event(self, event: TaskEvent) -> None:
91
+ """Add event to graph and notify listeners."""
92
+ with self._lock:
93
+ self._graph.add_event(event)
94
+ self._maybe_evict()
95
+
96
+ for listener in self._listeners:
97
+ with contextlib.suppress(Exception):
98
+ listener(event)
99
+
100
+ def get_node(self, task_id: str) -> TaskNode | None:
101
+ """Get node by ID, or None if not found."""
102
+ with self._lock:
103
+ return self._graph.get_node(task_id)
104
+
105
+ def get_nodes(
106
+ self,
107
+ *,
108
+ limit: int = 100,
109
+ offset: int = 0,
110
+ state: TaskState | None = None,
111
+ name_contains: str | None = None,
112
+ from_date: datetime | None = None,
113
+ to_date: datetime | None = None,
114
+ ) -> tuple[list[TaskNode], int]:
115
+ """Get nodes with optional filtering, most recent first.
116
+
117
+ Returns:
118
+ Tuple of (filtered nodes, total count matching filters).
119
+ """
120
+ with self._lock:
121
+ nodes = list(self._graph.nodes.values())
122
+
123
+ if state is not None:
124
+ nodes = [n for n in nodes if n.state == state]
125
+ if name_contains is not None:
126
+ name_lower = name_contains.lower()
127
+ nodes = [n for n in nodes if name_lower in n.name.lower()]
128
+ if from_date is not None:
129
+ from_dt = _ensure_tz_aware(from_date)
130
+ nodes = [n for n in nodes if n.events and n.events[-1].timestamp >= from_dt]
131
+ if to_date is not None:
132
+ to_dt = _ensure_end_of_day(to_date)
133
+ nodes = [n for n in nodes if n.events and n.events[0].timestamp <= to_dt]
134
+
135
+ nodes.sort(
136
+ key=lambda n: n.events[-1].timestamp if n.events else _MIN_DATETIME,
137
+ reverse=True,
138
+ )
139
+ total = len(nodes)
140
+ return nodes[offset : offset + limit], total
141
+
142
+ def get_root_nodes(
143
+ self,
144
+ *,
145
+ limit: int = 50,
146
+ offset: int = 0,
147
+ from_date: datetime | None = None,
148
+ to_date: datetime | None = None,
149
+ ) -> tuple[list[TaskNode], int]:
150
+ """Get root nodes (no parent), most recent first.
151
+
152
+ Returns:
153
+ Tuple of (filtered root nodes, total count matching filters).
154
+ """
155
+ with self._lock:
156
+ root_nodes = [
157
+ self._graph.nodes[rid]
158
+ for rid in self._graph.root_ids
159
+ if rid in self._graph.nodes
160
+ ]
161
+
162
+ # Date filtering - use child timestamps for synthetic nodes
163
+ if from_date is not None:
164
+ from_dt = _ensure_tz_aware(from_date)
165
+ root_nodes = [
166
+ n
167
+ for n in root_nodes
168
+ if _get_node_timestamp(n, self._graph) >= from_dt
169
+ ]
170
+ if to_date is not None:
171
+ to_dt = _ensure_end_of_day(to_date)
172
+ root_nodes = [
173
+ n
174
+ for n in root_nodes
175
+ if _get_first_timestamp(n, self._graph) <= to_dt
176
+ ]
177
+
178
+ # Sort while holding lock since we need access to graph for children
179
+ root_nodes.sort(
180
+ key=lambda n: _get_node_timestamp(n, self._graph),
181
+ reverse=True,
182
+ )
183
+ total = len(root_nodes)
184
+ return root_nodes[offset : offset + limit], total
185
+
186
+ def get_children(self, task_id: str) -> list[TaskNode]:
187
+ """Get child nodes of a task."""
188
+ with self._lock:
189
+ node = self._graph.get_node(task_id)
190
+ if node is None:
191
+ return []
192
+ return [
193
+ self._graph.nodes[cid]
194
+ for cid in node.children
195
+ if cid in self._graph.nodes
196
+ ]
197
+
198
+ def get_graph_from_root(self, root_id: str) -> dict[str, TaskNode]:
199
+ """Get all nodes in subgraph starting from root."""
200
+ with self._lock:
201
+ root = self._graph.get_node(root_id)
202
+ if root is None:
203
+ return {}
204
+
205
+ result: dict[str, TaskNode] = {}
206
+ to_visit = [root_id]
207
+
208
+ while to_visit:
209
+ current_id = to_visit.pop()
210
+ if current_id in result:
211
+ continue
212
+ node = self._graph.get_node(current_id)
213
+ if node is None:
214
+ continue
215
+ result[current_id] = node
216
+ to_visit.extend(node.children)
217
+
218
+ return result
219
+
220
+ def add_listener(self, callback: Callable[[TaskEvent], None]) -> None:
221
+ """Register callback for new events (used by WebSocket manager)."""
222
+ self._listeners.append(callback)
223
+
224
+ def remove_listener(self, callback: Callable[[TaskEvent], None]) -> None:
225
+ """Unregister an event listener."""
226
+ with contextlib.suppress(ValueError):
227
+ self._listeners.remove(callback)
228
+
229
+ @property
230
+ def node_count(self) -> int:
231
+ """Current node count."""
232
+ with self._lock:
233
+ return len(self._graph.nodes)
234
+
235
+ def get_unique_task_names(self) -> set[str]:
236
+ """Get all unique task names seen in events."""
237
+ with self._lock:
238
+ return {node.name for node in self._graph.nodes.values()}
239
+
240
+ def _maybe_evict(self) -> None:
241
+ """Evict oldest 10% when over capacity. Call with lock held."""
242
+ if len(self._graph.nodes) <= self._max_nodes:
243
+ return
244
+
245
+ nodes_by_age = sorted(
246
+ self._graph.nodes.values(),
247
+ key=lambda n: n.events[0].timestamp if n.events else _MIN_DATETIME,
248
+ )
249
+
250
+ to_remove = len(nodes_by_age) - int(self._max_nodes * 0.9)
251
+ for node in nodes_by_age[:to_remove]:
252
+ if node.parent_id and node.parent_id in self._graph.nodes:
253
+ parent = self._graph.nodes[node.parent_id]
254
+ if node.task_id in parent.children:
255
+ parent.children.remove(node.task_id)
256
+
257
+ if node.task_id in self._graph.root_ids:
258
+ self._graph.root_ids.remove(node.task_id)
259
+
260
+ del self._graph.nodes[node.task_id]
@@ -0,0 +1,5 @@
1
+ """UI module - React SPA for task visualization."""
2
+
3
+ from stemtrace.server.ui.static import get_static_router, is_ui_available
4
+
5
+ __all__ = ["get_static_router", "is_ui_available"]