htmlgraph 0.27.7__py3-none-any.whl → 0.28.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 (29) hide show
  1. htmlgraph/__init__.py +1 -1
  2. htmlgraph/api/broadcast.py +316 -0
  3. htmlgraph/api/broadcast_routes.py +357 -0
  4. htmlgraph/api/broadcast_websocket.py +115 -0
  5. htmlgraph/api/cost_alerts_websocket.py +7 -16
  6. htmlgraph/api/main.py +110 -1
  7. htmlgraph/api/offline.py +776 -0
  8. htmlgraph/api/presence.py +446 -0
  9. htmlgraph/api/reactive.py +455 -0
  10. htmlgraph/api/reactive_routes.py +195 -0
  11. htmlgraph/api/static/broadcast-demo.html +393 -0
  12. htmlgraph/api/sync_routes.py +184 -0
  13. htmlgraph/api/websocket.py +112 -37
  14. htmlgraph/broadcast_integration.py +227 -0
  15. htmlgraph/cli_commands/sync.py +207 -0
  16. htmlgraph/db/schema.py +214 -0
  17. htmlgraph/hooks/event_tracker.py +53 -2
  18. htmlgraph/reactive_integration.py +148 -0
  19. htmlgraph/sync/__init__.py +21 -0
  20. htmlgraph/sync/git_sync.py +458 -0
  21. {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.0.dist-info}/METADATA +1 -1
  22. {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.0.dist-info}/RECORD +29 -15
  23. {htmlgraph-0.27.7.data → htmlgraph-0.28.0.data}/data/htmlgraph/dashboard.html +0 -0
  24. {htmlgraph-0.27.7.data → htmlgraph-0.28.0.data}/data/htmlgraph/styles.css +0 -0
  25. {htmlgraph-0.27.7.data → htmlgraph-0.28.0.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  26. {htmlgraph-0.27.7.data → htmlgraph-0.28.0.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  27. {htmlgraph-0.27.7.data → htmlgraph-0.28.0.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  28. {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.0.dist-info}/WHEEL +0 -0
  29. {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,455 @@
1
+ """
2
+ Reactive Query System - Phase 3
3
+
4
+ Dashboard queries that auto-update when underlying data changes.
5
+ No more manual refresh - changes propagate instantly via WebSocket.
6
+
7
+ Features:
8
+ - Register queries with SQL templates
9
+ - Automatic invalidation on data changes
10
+ - Dependency tracking (queries know what data they depend on)
11
+ - Sub-100ms query update latency
12
+ - WebSocket push notifications to subscribers
13
+
14
+ Example:
15
+ >>> manager = ReactiveQueryManager(websocket_manager, db_path)
16
+ >>> await manager.register_default_queries(db)
17
+ >>>
18
+ >>> # Subscribe client to query
19
+ >>> result = await manager.subscribe_to_query("features_by_status", client_id)
20
+ >>>
21
+ >>> # When feature status changes, query auto-updates
22
+ >>> await manager.on_resource_updated("feat-123", "feature")
23
+ >>> # Client receives new query result automatically
24
+ """
25
+
26
+ import logging
27
+ import time
28
+ from dataclasses import dataclass
29
+ from datetime import datetime
30
+ from typing import Any
31
+
32
+ import aiosqlite
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ @dataclass
38
+ class QueryDefinition:
39
+ """Definition of a reactive query."""
40
+
41
+ query_id: str
42
+ name: str
43
+ description: str
44
+ sql: str # SQL query template
45
+ depends_on: list[
46
+ str
47
+ ] # Resource patterns it depends on (e.g., "*features", "feat-123")
48
+ refresh_interval_ms: int = 5000 # How often to refresh if no changes
49
+
50
+
51
+ @dataclass
52
+ class QueryResult:
53
+ """Result of executing a query."""
54
+
55
+ query_id: str
56
+ timestamp: datetime
57
+ rows: list[dict[str, Any]]
58
+ row_count: int = 0
59
+ execution_time_ms: float = 0.0
60
+
61
+ def __post_init__(self) -> None:
62
+ self.row_count = len(self.rows)
63
+
64
+ def to_dict(self) -> dict[str, Any]:
65
+ """Convert to dictionary for JSON serialization."""
66
+ return {
67
+ "query_id": self.query_id,
68
+ "timestamp": self.timestamp.isoformat(),
69
+ "rows": self.rows,
70
+ "row_count": self.row_count,
71
+ "execution_time_ms": self.execution_time_ms,
72
+ }
73
+
74
+
75
+ class ReactiveQuery:
76
+ """Single reactive query with change detection."""
77
+
78
+ def __init__(self, definition: QueryDefinition, db_path: str):
79
+ """
80
+ Initialize reactive query.
81
+
82
+ Args:
83
+ definition: Query definition
84
+ db_path: Path to SQLite database
85
+ """
86
+ self.definition = definition
87
+ self.db_path = db_path
88
+ self.subscribers: list[str] = [] # client_ids subscribed
89
+ self.last_result: QueryResult | None = None
90
+ self.last_execution: datetime | None = None
91
+
92
+ async def execute(self) -> QueryResult:
93
+ """Execute query and return result."""
94
+ start_time = time.time()
95
+
96
+ try:
97
+ async with aiosqlite.connect(self.db_path) as db:
98
+ db.row_factory = aiosqlite.Row
99
+ cursor = await db.execute(self.definition.sql)
100
+ rows = await cursor.fetchall()
101
+
102
+ # Convert rows to dicts
103
+ row_dicts: list[dict[str, Any]] = []
104
+ rows_list = list(rows)
105
+ if rows_list:
106
+ columns = list(rows_list[0].keys())
107
+ row_dicts = [dict(zip(columns, row)) for row in rows_list]
108
+
109
+ result = QueryResult(
110
+ query_id=self.definition.query_id,
111
+ timestamp=datetime.now(),
112
+ rows=row_dicts,
113
+ execution_time_ms=(time.time() - start_time) * 1000,
114
+ )
115
+
116
+ self.last_execution = datetime.now()
117
+ return result
118
+
119
+ except Exception as e:
120
+ logger.error(f"Query {self.definition.query_id} failed: {e}")
121
+ return QueryResult(
122
+ query_id=self.definition.query_id,
123
+ timestamp=datetime.now(),
124
+ rows=[],
125
+ execution_time_ms=(time.time() - start_time) * 1000,
126
+ )
127
+
128
+ async def has_changed(self) -> bool:
129
+ """Check if query result has changed since last execution."""
130
+ if self.last_result is None:
131
+ return True
132
+
133
+ # Store old rows for comparison
134
+ old_rows = self.last_result.rows
135
+
136
+ new_result = await self.execute()
137
+
138
+ # Simple comparison: check if rows changed
139
+ changed = new_result.rows != old_rows
140
+
141
+ self.last_result = new_result
142
+ return changed
143
+
144
+ def add_subscriber(self, client_id: str) -> None:
145
+ """Add client to subscribers list."""
146
+ if client_id not in self.subscribers:
147
+ self.subscribers.append(client_id)
148
+
149
+ def remove_subscriber(self, client_id: str) -> None:
150
+ """Remove client from subscribers."""
151
+ if client_id in self.subscribers:
152
+ self.subscribers.remove(client_id)
153
+
154
+ def get_subscribers(self) -> list[str]:
155
+ """Get all subscribed clients."""
156
+ return self.subscribers.copy()
157
+
158
+
159
+ class ReactiveQueryManager:
160
+ """
161
+ Manages all reactive queries and handles invalidation.
162
+
163
+ Tracks dependencies between queries and resources, automatically
164
+ invalidating and re-executing queries when dependent data changes.
165
+ """
166
+
167
+ def __init__(self, websocket_manager: Any, db_path: str):
168
+ """
169
+ Initialize reactive query manager.
170
+
171
+ Args:
172
+ websocket_manager: WebSocketManager for broadcasting updates
173
+ db_path: Path to SQLite database
174
+ """
175
+ self.websocket_manager = websocket_manager
176
+ self.db_path = db_path
177
+ self.queries: dict[str, ReactiveQuery] = {}
178
+ self.dependencies: dict[str, set[str]] = {} # resource_pattern -> query_ids
179
+
180
+ def register_query(self, definition: QueryDefinition) -> None:
181
+ """
182
+ Register a new reactive query.
183
+
184
+ Args:
185
+ definition: Query definition with SQL and dependencies
186
+ """
187
+ query = ReactiveQuery(definition, self.db_path)
188
+ self.queries[definition.query_id] = query
189
+
190
+ # Track dependencies
191
+ for dep in definition.depends_on:
192
+ if dep not in self.dependencies:
193
+ self.dependencies[dep] = set()
194
+ self.dependencies[dep].add(definition.query_id)
195
+
196
+ logger.info(f"Registered query: {definition.query_id} ({definition.name})")
197
+
198
+ async def register_default_queries(self) -> None:
199
+ """Register common queries used by dashboard."""
200
+
201
+ # Query 1: Features by status
202
+ self.register_query(
203
+ QueryDefinition(
204
+ query_id="features_by_status",
205
+ name="Features by Status",
206
+ description="Count of features in each status",
207
+ sql="""
208
+ SELECT status, COUNT(*) as count
209
+ FROM features
210
+ GROUP BY status
211
+ ORDER BY status
212
+ """,
213
+ depends_on=["*features"], # Depends on all features
214
+ )
215
+ )
216
+
217
+ # Query 2: Agent workload
218
+ self.register_query(
219
+ QueryDefinition(
220
+ query_id="agent_workload",
221
+ name="Agent Workload",
222
+ description="Active tasks per agent",
223
+ sql="""
224
+ SELECT
225
+ agent_id,
226
+ COUNT(DISTINCT session_id) as active_sessions,
227
+ COUNT(*) as total_events
228
+ FROM agent_events
229
+ WHERE timestamp > datetime('now', '-1 hour')
230
+ GROUP BY agent_id
231
+ ORDER BY total_events DESC
232
+ """,
233
+ depends_on=["*events"],
234
+ )
235
+ )
236
+
237
+ # Query 3: Recent activity
238
+ self.register_query(
239
+ QueryDefinition(
240
+ query_id="recent_activity",
241
+ name="Recent Activity",
242
+ description="Last 20 tool executions",
243
+ sql="""
244
+ SELECT
245
+ event_id, agent_id, tool_name,
246
+ input_summary, timestamp
247
+ FROM agent_events
248
+ WHERE event_type = 'tool_call'
249
+ ORDER BY timestamp DESC
250
+ LIMIT 20
251
+ """,
252
+ depends_on=["*events"],
253
+ )
254
+ )
255
+
256
+ # Query 4: Blocked features
257
+ self.register_query(
258
+ QueryDefinition(
259
+ query_id="blocked_features",
260
+ name="Blocked Features",
261
+ description="Features waiting on other features",
262
+ sql="""
263
+ SELECT id, title, status, created_at
264
+ FROM features
265
+ WHERE status = 'blocked'
266
+ ORDER BY created_at DESC
267
+ """,
268
+ depends_on=["*features"],
269
+ )
270
+ )
271
+
272
+ # Query 5: Cost trends (hourly)
273
+ self.register_query(
274
+ QueryDefinition(
275
+ query_id="cost_trends",
276
+ name="Cost Trends",
277
+ description="Cost aggregated by hour",
278
+ sql="""
279
+ SELECT
280
+ strftime('%Y-%m-%d %H:00', timestamp) as hour,
281
+ SUM(cost_tokens) as total_tokens,
282
+ COUNT(*) as event_count
283
+ FROM agent_events
284
+ WHERE cost_tokens > 0
285
+ GROUP BY hour
286
+ ORDER BY hour DESC
287
+ LIMIT 24
288
+ """,
289
+ depends_on=["*events"],
290
+ )
291
+ )
292
+
293
+ # Query 6: Active sessions
294
+ self.register_query(
295
+ QueryDefinition(
296
+ query_id="active_sessions",
297
+ name="Active Sessions",
298
+ description="Currently active agent sessions",
299
+ sql="""
300
+ SELECT
301
+ session_id, agent_assigned,
302
+ created_at, updated_at
303
+ FROM sessions
304
+ WHERE status = 'active'
305
+ ORDER BY updated_at DESC
306
+ """,
307
+ depends_on=["*sessions"],
308
+ )
309
+ )
310
+
311
+ logger.info(f"Registered {len(self.queries)} default queries")
312
+
313
+ async def on_resource_updated(self, resource_id: str, resource_type: str) -> None:
314
+ """
315
+ Called when a resource (feature, track, event, etc.) is updated.
316
+
317
+ Invalidates all queries that depend on this resource.
318
+
319
+ Args:
320
+ resource_id: ID of updated resource (e.g., "feat-123")
321
+ resource_type: Type of resource (e.g., "feature", "event", "session")
322
+ """
323
+ affected_queries: set[str] = set()
324
+
325
+ # Check exact match dependencies
326
+ if resource_id in self.dependencies:
327
+ affected_queries.update(self.dependencies[resource_id])
328
+
329
+ # Check wildcard dependencies (e.g., "*features")
330
+ wildcard_key = f"*{resource_type}s"
331
+ if wildcard_key in self.dependencies:
332
+ affected_queries.update(self.dependencies[wildcard_key])
333
+
334
+ # Re-execute affected queries and notify subscribers
335
+ for query_id in affected_queries:
336
+ if query_id in self.queries:
337
+ await self.invalidate_query(query_id)
338
+
339
+ async def invalidate_query(self, query_id: str) -> None:
340
+ """
341
+ Re-execute query and broadcast new result to subscribers.
342
+
343
+ Args:
344
+ query_id: Query to invalidate
345
+ """
346
+ if query_id not in self.queries:
347
+ logger.warning(f"Query not found: {query_id}")
348
+ return
349
+
350
+ query = self.queries[query_id]
351
+
352
+ # Execute query
353
+ result = await query.execute()
354
+ query.last_result = result
355
+
356
+ # Broadcast to subscribers
357
+ subscribers_notified = 0
358
+ for client_id in query.get_subscribers():
359
+ try:
360
+ # Send update via WebSocketManager's broadcast method
361
+ # Note: Using a pseudo-session "queries" for query subscriptions
362
+ sent = await self.websocket_manager.broadcast_to_session(
363
+ session_id="queries",
364
+ event={
365
+ "type": "query_update",
366
+ "query_id": query_id,
367
+ **result.to_dict(),
368
+ },
369
+ )
370
+ subscribers_notified += sent
371
+ except Exception as e:
372
+ logger.error(f"Error broadcasting to {client_id}: {e}")
373
+ query.remove_subscriber(client_id)
374
+
375
+ logger.debug(
376
+ f"Query {query_id} invalidated, notified {subscribers_notified} subscribers"
377
+ )
378
+
379
+ async def subscribe_to_query(
380
+ self, query_id: str, client_id: str
381
+ ) -> QueryResult | None:
382
+ """
383
+ Subscribe client to query and return initial result.
384
+
385
+ Args:
386
+ query_id: Query to subscribe to
387
+ client_id: Client subscribing
388
+
389
+ Returns:
390
+ Initial query result or None if query not found
391
+ """
392
+ if query_id not in self.queries:
393
+ logger.warning(f"Query not found: {query_id}")
394
+ return None
395
+
396
+ query = self.queries[query_id]
397
+ query.add_subscriber(client_id)
398
+
399
+ # Return initial result (execute if not cached)
400
+ if query.last_result is None:
401
+ query.last_result = await query.execute()
402
+
403
+ logger.info(f"Client {client_id} subscribed to query {query_id}")
404
+ return query.last_result
405
+
406
+ def unsubscribe_from_query(self, query_id: str, client_id: str) -> None:
407
+ """
408
+ Unsubscribe client from query.
409
+
410
+ Args:
411
+ query_id: Query to unsubscribe from
412
+ client_id: Client unsubscribing
413
+ """
414
+ if query_id in self.queries:
415
+ self.queries[query_id].remove_subscriber(client_id)
416
+ logger.info(f"Client {client_id} unsubscribed from query {query_id}")
417
+
418
+ async def get_query_result(self, query_id: str) -> QueryResult | None:
419
+ """
420
+ Get current result of a query.
421
+
422
+ Args:
423
+ query_id: Query to get result for
424
+
425
+ Returns:
426
+ Query result or None if not found
427
+ """
428
+ if query_id not in self.queries:
429
+ return None
430
+
431
+ query = self.queries[query_id]
432
+ if query.last_result is None:
433
+ query.last_result = await query.execute()
434
+
435
+ return query.last_result
436
+
437
+ def list_queries(self) -> list[dict[str, Any]]:
438
+ """
439
+ List all registered queries.
440
+
441
+ Returns:
442
+ List of query metadata
443
+ """
444
+ return [
445
+ {
446
+ "query_id": q.definition.query_id,
447
+ "name": q.definition.name,
448
+ "description": q.definition.description,
449
+ "subscribers": len(q.get_subscribers()),
450
+ "last_execution": q.last_execution.isoformat()
451
+ if q.last_execution
452
+ else None,
453
+ }
454
+ for q in self.queries.values()
455
+ ]
@@ -0,0 +1,195 @@
1
+ """
2
+ Reactive Query API Routes - WebSocket and HTTP Endpoints
3
+
4
+ Provides API endpoints for:
5
+ - WebSocket subscriptions to reactive queries
6
+ - HTTP endpoints for query management
7
+ - Query listing and metadata
8
+
9
+ Example WebSocket usage:
10
+ const ws = new WebSocket('ws://localhost:8000/ws/query/features_by_status');
11
+ ws.onmessage = (event) => {
12
+ const result = JSON.parse(event.data);
13
+ // result contains: { type: "query_state", query_id, rows, row_count, ... }
14
+ updateDashboard(result);
15
+ };
16
+
17
+ Example HTTP usage:
18
+ GET /api/query/features_by_status
19
+ Returns: { query_id, timestamp, rows, row_count, execution_time_ms }
20
+
21
+ GET /api/queries
22
+ Returns: { queries: [...] }
23
+ """
24
+
25
+ import logging
26
+ import uuid
27
+ from typing import Any
28
+
29
+ from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # Global reactive query manager instance (set by main app)
34
+ reactive_query_manager: Any = None
35
+
36
+
37
+ def set_reactive_query_manager(manager: Any) -> None:
38
+ """Set global reactive query manager instance."""
39
+ global reactive_query_manager
40
+ reactive_query_manager = manager
41
+
42
+
43
+ def create_reactive_routes() -> APIRouter:
44
+ """Create FastAPI router for reactive query endpoints."""
45
+ router = APIRouter(prefix="/api", tags=["reactive-queries"])
46
+
47
+ @router.websocket("/ws/query/{query_id}")
48
+ async def websocket_query_endpoint(websocket: WebSocket, query_id: str) -> None:
49
+ """
50
+ WebSocket endpoint for reactive query subscriptions.
51
+
52
+ Client receives:
53
+ - Initial query result on connection
54
+ - Updates whenever query result changes
55
+
56
+ Args:
57
+ websocket: FastAPI WebSocket connection
58
+ query_id: Query to subscribe to
59
+ """
60
+ if reactive_query_manager is None:
61
+ logger.error("ReactiveQueryManager not initialized")
62
+ await websocket.close(code=1011) # Internal error
63
+ return
64
+
65
+ client_id = str(uuid.uuid4())
66
+
67
+ try:
68
+ await websocket.accept()
69
+
70
+ # Subscribe to query
71
+ initial_result = await reactive_query_manager.subscribe_to_query(
72
+ query_id, client_id
73
+ )
74
+
75
+ if initial_result is None:
76
+ logger.warning(f"Query not found: {query_id}")
77
+ await websocket.close(code=1008) # Policy violation (not found)
78
+ return
79
+
80
+ # Send initial result
81
+ await websocket.send_json(
82
+ {
83
+ "type": "query_state",
84
+ **initial_result.to_dict(),
85
+ }
86
+ )
87
+
88
+ logger.info(
89
+ f"Client {client_id} subscribed to query {query_id}, "
90
+ f"sent {initial_result.row_count} rows"
91
+ )
92
+
93
+ # Keep connection open to receive updates
94
+ # Updates are pushed from server via broadcast
95
+ while True:
96
+ try:
97
+ # Receive heartbeat/ping from client
98
+ data = await websocket.receive_text()
99
+
100
+ if data == "ping":
101
+ await websocket.send_json({"type": "pong"})
102
+ else:
103
+ logger.debug(f"Received message from {client_id}: {data}")
104
+
105
+ except WebSocketDisconnect:
106
+ break
107
+ except Exception as e:
108
+ logger.error(f"Error handling message from {client_id}: {e}")
109
+ break
110
+
111
+ except Exception as e:
112
+ logger.error(f"WebSocket error for query {query_id}: {e}")
113
+
114
+ finally:
115
+ # Cleanup
116
+ reactive_query_manager.unsubscribe_from_query(query_id, client_id)
117
+ logger.info(f"Client {client_id} unsubscribed from query {query_id}")
118
+
119
+ @router.get("/query/{query_id}")
120
+ async def get_query_result(query_id: str) -> dict[str, Any]:
121
+ """
122
+ Get current result of a query (HTTP endpoint).
123
+
124
+ Args:
125
+ query_id: Query to retrieve
126
+
127
+ Returns:
128
+ Query result with rows and metadata
129
+
130
+ Raises:
131
+ HTTPException: If query not found
132
+ """
133
+ if reactive_query_manager is None:
134
+ raise HTTPException(
135
+ status_code=500, detail="ReactiveQueryManager not initialized"
136
+ )
137
+
138
+ result = await reactive_query_manager.get_query_result(query_id)
139
+
140
+ if result is None:
141
+ raise HTTPException(status_code=404, detail=f"Query not found: {query_id}")
142
+
143
+ result_dict: dict[str, Any] = result.to_dict()
144
+ return result_dict
145
+
146
+ @router.get("/queries")
147
+ async def list_queries() -> dict[str, Any]:
148
+ """
149
+ List all registered queries.
150
+
151
+ Returns:
152
+ Dictionary with list of queries and metadata
153
+ """
154
+ if reactive_query_manager is None:
155
+ raise HTTPException(
156
+ status_code=500, detail="ReactiveQueryManager not initialized"
157
+ )
158
+
159
+ queries = reactive_query_manager.list_queries()
160
+
161
+ return {
162
+ "queries": queries,
163
+ "count": len(queries),
164
+ }
165
+
166
+ @router.post("/query/{query_id}/invalidate")
167
+ async def invalidate_query(query_id: str) -> dict[str, Any]:
168
+ """
169
+ Manually invalidate a query (force re-execution).
170
+
171
+ Args:
172
+ query_id: Query to invalidate
173
+
174
+ Returns:
175
+ Success message
176
+
177
+ Raises:
178
+ HTTPException: If query not found
179
+ """
180
+ if reactive_query_manager is None:
181
+ raise HTTPException(
182
+ status_code=500, detail="ReactiveQueryManager not initialized"
183
+ )
184
+
185
+ if query_id not in reactive_query_manager.queries:
186
+ raise HTTPException(status_code=404, detail=f"Query not found: {query_id}")
187
+
188
+ await reactive_query_manager.invalidate_query(query_id)
189
+
190
+ return {
191
+ "status": "success",
192
+ "message": f"Query {query_id} invalidated",
193
+ }
194
+
195
+ return router