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.
- stemtrace/__init__.py +173 -0
- stemtrace/core/__init__.py +15 -0
- stemtrace/core/events.py +67 -0
- stemtrace/core/exceptions.py +25 -0
- stemtrace/core/graph.py +348 -0
- stemtrace/core/ports.py +42 -0
- stemtrace/library/__init__.py +1 -0
- stemtrace/library/bootsteps.py +169 -0
- stemtrace/library/config.py +55 -0
- stemtrace/library/scrubbing.py +195 -0
- stemtrace/library/signals.py +434 -0
- stemtrace/library/transports/__init__.py +36 -0
- stemtrace/library/transports/memory.py +33 -0
- stemtrace/library/transports/redis.py +92 -0
- stemtrace/py.typed +0 -0
- stemtrace/server/__init__.py +24 -0
- stemtrace/server/__main__.py +136 -0
- stemtrace/server/api/__init__.py +29 -0
- stemtrace/server/api/routes.py +270 -0
- stemtrace/server/api/schemas.py +136 -0
- stemtrace/server/api/websocket.py +28 -0
- stemtrace/server/consumer.py +133 -0
- stemtrace/server/fastapi/__init__.py +13 -0
- stemtrace/server/fastapi/auth.py +77 -0
- stemtrace/server/fastapi/extension.py +121 -0
- stemtrace/server/fastapi/router.py +42 -0
- stemtrace/server/store.py +260 -0
- stemtrace/server/ui/__init__.py +5 -0
- stemtrace/server/ui/frontend/biome.json +58 -0
- stemtrace/server/ui/frontend/dist/assets/index-CCs7iO1n.css +1 -0
- stemtrace/server/ui/frontend/dist/assets/index-Dao0_Q6x.js +14 -0
- stemtrace/server/ui/frontend/dist/index.html +17 -0
- stemtrace/server/ui/frontend/index.html +16 -0
- stemtrace/server/ui/frontend/package-lock.json +4049 -0
- stemtrace/server/ui/frontend/package.json +46 -0
- stemtrace/server/ui/frontend/playwright.config.ts +40 -0
- stemtrace/server/ui/frontend/postcss.config.js +5 -0
- stemtrace/server/ui/frontend/src/api/client.ts +173 -0
- stemtrace/server/ui/frontend/src/api/queries.ts +151 -0
- stemtrace/server/ui/frontend/src/components/DateRangePicker.tsx +244 -0
- stemtrace/server/ui/frontend/src/components/Filters.tsx +116 -0
- stemtrace/server/ui/frontend/src/components/TaskGraph.tsx +453 -0
- stemtrace/server/ui/frontend/src/components/TaskList.tsx +172 -0
- stemtrace/server/ui/frontend/src/components/TaskStateBadge.tsx +27 -0
- stemtrace/server/ui/frontend/src/components/TaskTimeline.tsx +144 -0
- stemtrace/server/ui/frontend/src/components/TimelineBar.tsx +224 -0
- stemtrace/server/ui/frontend/src/components/Tooltip.tsx +54 -0
- stemtrace/server/ui/frontend/src/hooks/WebSocketContext.tsx +143 -0
- stemtrace/server/ui/frontend/src/hooks/useInfiniteScroll.ts +56 -0
- stemtrace/server/ui/frontend/src/index.css +104 -0
- stemtrace/server/ui/frontend/src/main.tsx +44 -0
- stemtrace/server/ui/frontend/src/routes/__root.tsx +103 -0
- stemtrace/server/ui/frontend/src/routes/graph.$rootId.tsx +51 -0
- stemtrace/server/ui/frontend/src/routes/graphs.tsx +186 -0
- stemtrace/server/ui/frontend/src/routes/index.tsx +119 -0
- stemtrace/server/ui/frontend/src/routes/registry.tsx +211 -0
- stemtrace/server/ui/frontend/src/routes/tasks.$taskId.tsx +313 -0
- stemtrace/server/ui/frontend/src/utils/format.ts +38 -0
- stemtrace/server/ui/frontend/tests/graphs.spec.ts +485 -0
- stemtrace/server/ui/frontend/tests/registry.spec.ts +117 -0
- stemtrace/server/ui/frontend/tests/task-detail.spec.ts +147 -0
- stemtrace/server/ui/frontend/tests/tasks.spec.ts +92 -0
- stemtrace/server/ui/frontend/tsconfig.json +32 -0
- stemtrace/server/ui/frontend/tsconfig.tsbuildinfo +1 -0
- stemtrace/server/ui/frontend/vite.config.ts +30 -0
- stemtrace/server/ui/static.py +56 -0
- stemtrace/server/websocket.py +99 -0
- stemtrace-0.1.0.dist-info/METADATA +428 -0
- stemtrace-0.1.0.dist-info/RECORD +72 -0
- stemtrace-0.1.0.dist-info/WHEEL +4 -0
- stemtrace-0.1.0.dist-info/entry_points.txt +2 -0
- 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]
|