htmlgraph 0.27.7__py3-none-any.whl → 0.28.1__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 (34) 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 +135 -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/static/presence-widget-demo.html +785 -0
  13. htmlgraph/api/sync_routes.py +184 -0
  14. htmlgraph/api/templates/partials/agents.html +308 -80
  15. htmlgraph/api/websocket.py +112 -37
  16. htmlgraph/broadcast_integration.py +227 -0
  17. htmlgraph/cli_commands/sync.py +207 -0
  18. htmlgraph/db/schema.py +226 -0
  19. htmlgraph/hooks/event_tracker.py +53 -2
  20. htmlgraph/models.py +1 -0
  21. htmlgraph/reactive_integration.py +148 -0
  22. htmlgraph/session_manager.py +7 -0
  23. htmlgraph/sync/__init__.py +21 -0
  24. htmlgraph/sync/git_sync.py +458 -0
  25. {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.1.dist-info}/METADATA +1 -1
  26. {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.1.dist-info}/RECORD +32 -19
  27. htmlgraph/dashboard.html +0 -6592
  28. htmlgraph-0.27.7.data/data/htmlgraph/dashboard.html +0 -6592
  29. {htmlgraph-0.27.7.data → htmlgraph-0.28.1.data}/data/htmlgraph/styles.css +0 -0
  30. {htmlgraph-0.27.7.data → htmlgraph-0.28.1.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  31. {htmlgraph-0.27.7.data → htmlgraph-0.28.1.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  32. {htmlgraph-0.27.7.data → htmlgraph-0.28.1.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  33. {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.1.dist-info}/WHEEL +0 -0
  34. {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,115 @@
1
+ """
2
+ Broadcast WebSocket Endpoint - Real-time Cross-Session Updates
3
+
4
+ WebSocket endpoint for receiving broadcast events from other sessions.
5
+ Clients subscribe to get notified when features/tracks/spikes are updated.
6
+ """
7
+
8
+ import logging
9
+ import uuid
10
+ from typing import Any
11
+
12
+ from fastapi import WebSocket, WebSocketDisconnect
13
+
14
+ from htmlgraph.api.broadcast import BroadcastEventType
15
+ from htmlgraph.api.websocket import EventSubscriptionFilter, WebSocketManager
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ async def websocket_broadcasts_endpoint(
21
+ websocket: WebSocket,
22
+ websocket_manager: WebSocketManager,
23
+ get_db: Any,
24
+ ) -> None:
25
+ """
26
+ WebSocket endpoint for cross-session broadcast events.
27
+
28
+ Clients connect and receive real-time notifications when:
29
+ - Features are created/updated/deleted
30
+ - Tracks are updated
31
+ - Spikes are updated
32
+ - Status changes occur
33
+ - Links are added
34
+
35
+ Example client code:
36
+ ```javascript
37
+ const ws = new WebSocket('ws://localhost:8000/ws/broadcasts');
38
+ ws.onmessage = (event) => {
39
+ const msg = JSON.parse(event.data);
40
+ if (msg.type === 'broadcast_event') {
41
+ handleBroadcastEvent(msg);
42
+ }
43
+ };
44
+ ```
45
+
46
+ Args:
47
+ websocket: FastAPI WebSocket connection
48
+ websocket_manager: WebSocketManager for event distribution
49
+ get_db: Database connection factory
50
+ """
51
+ client_id = str(uuid.uuid4())
52
+
53
+ # Create subscription filter for broadcast events
54
+ subscription_filter = EventSubscriptionFilter(
55
+ event_types=[
56
+ "broadcast_event",
57
+ BroadcastEventType.FEATURE_UPDATED.value,
58
+ BroadcastEventType.FEATURE_CREATED.value,
59
+ BroadcastEventType.FEATURE_DELETED.value,
60
+ BroadcastEventType.TRACK_UPDATED.value,
61
+ BroadcastEventType.SPIKE_UPDATED.value,
62
+ BroadcastEventType.STATUS_CHANGED.value,
63
+ BroadcastEventType.LINK_ADDED.value,
64
+ BroadcastEventType.COMMENT_ADDED.value,
65
+ ]
66
+ )
67
+
68
+ # Connect to global broadcast channel
69
+ # Note: Using "broadcasts" as a pseudo-session ID for global channel
70
+ connected = await websocket_manager.connect(
71
+ websocket=websocket,
72
+ session_id="broadcasts",
73
+ client_id=client_id,
74
+ subscription_filter=subscription_filter,
75
+ )
76
+
77
+ if not connected:
78
+ logger.warning(f"Failed to connect broadcast client {client_id}")
79
+ return
80
+
81
+ logger.info(f"Broadcast client connected: {client_id}")
82
+
83
+ try:
84
+ # Keep connection alive and handle incoming messages
85
+ while True:
86
+ try:
87
+ # Receive messages from client (for heartbeat/ping)
88
+ data = await websocket.receive_text()
89
+
90
+ # Handle client messages
91
+ if data == "ping":
92
+ await websocket.send_json({"type": "pong"})
93
+ elif data == "subscribe":
94
+ # Client can request initial state sync here
95
+ await websocket.send_json(
96
+ {
97
+ "type": "subscribed",
98
+ "client_id": client_id,
99
+ "message": "Connected to broadcast channel",
100
+ }
101
+ )
102
+ else:
103
+ logger.debug(f"Received message from client {client_id}: {data}")
104
+
105
+ except WebSocketDisconnect:
106
+ logger.info(f"Broadcast client disconnected: {client_id}")
107
+ break
108
+ except Exception as e:
109
+ logger.error(f"Error handling client message: {e}")
110
+ break
111
+
112
+ finally:
113
+ # Cleanup on disconnect
114
+ await websocket_manager.disconnect("broadcasts", client_id)
115
+ logger.info(f"Broadcast client cleanup completed: {client_id}")
@@ -132,14 +132,11 @@ class CostAlertStreamManager:
132
132
  client_id: Client ID
133
133
  cost_alert_filter: Subscription filter for alerts
134
134
  """
135
- if (
136
- session_id not in self.websocket_manager.connections
137
- or client_id not in self.websocket_manager.connections[session_id]
138
- ):
139
- logger.warning(f"Client not found: {session_id}/{client_id}")
135
+ if client_id not in self.websocket_manager.connections:
136
+ logger.warning(f"Client not found: {client_id}")
140
137
  return
141
138
 
142
- client = self.websocket_manager.connections[session_id][client_id]
139
+ client = self.websocket_manager.connections[client_id]
143
140
  last_alert_time = self.last_alert_timestamp.get(
144
141
  session_id, "1970-01-01T00:00:00Z"
145
142
  )
@@ -280,13 +277,10 @@ class CostAlertStreamManager:
280
277
  client_id: Client ID
281
278
  update_interval_seconds: How often to send updates
282
279
  """
283
- if (
284
- session_id not in self.websocket_manager.connections
285
- or client_id not in self.websocket_manager.connections[session_id]
286
- ):
280
+ if client_id not in self.websocket_manager.connections:
287
281
  return
288
282
 
289
- client = self.websocket_manager.connections[session_id][client_id]
283
+ client = self.websocket_manager.connections[client_id]
290
284
 
291
285
  try:
292
286
  while True:
@@ -337,13 +331,10 @@ class CostAlertStreamManager:
337
331
  update_interval_seconds: How often to update predictions
338
332
  lookback_minutes: How far back to analyze
339
333
  """
340
- if (
341
- session_id not in self.websocket_manager.connections
342
- or client_id not in self.websocket_manager.connections[session_id]
343
- ):
334
+ if client_id not in self.websocket_manager.connections:
344
335
  return
345
336
 
346
- client = self.websocket_manager.connections[session_id][client_id]
337
+ client = self.websocket_manager.connections[client_id]
347
338
 
348
339
  try:
349
340
  while True:
htmlgraph/api/main.py CHANGED
@@ -25,7 +25,7 @@ from pathlib import Path
25
25
  from typing import Any
26
26
 
27
27
  import aiosqlite
28
- from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
28
+ from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
29
29
  from fastapi.responses import HTMLResponse
30
30
  from fastapi.staticfiles import StaticFiles
31
31
  from fastapi.templating import Jinja2Templates
@@ -380,6 +380,31 @@ def get_app(db_path: str) -> FastAPI:
380
380
  finally:
381
381
  await db.close()
382
382
 
383
+ # ========== PRESENCE WIDGET DEMO (Phase 6) ==========
384
+
385
+ @app.get("/views/presence-widget", response_class=HTMLResponse)
386
+ async def presence_widget_demo(request: Request) -> HTMLResponse:
387
+ """Phase 6 Demo: Real-time Presence Widget showing active agents across sessions.
388
+
389
+ This widget demonstrates:
390
+ - WebSocket connections to broadcast stream
391
+ - Real-time presence updates (<100ms latency)
392
+ - Multi-session agent coordination
393
+ - Integration with Phase 5 broadcast API
394
+
395
+ Try it out:
396
+ 1. Open this page in your browser
397
+ 2. In another terminal, emit presence events
398
+ 3. Watch the dashboard update in real-time!
399
+ """
400
+ widget_path = Path(__file__).parent / "static" / "presence-widget-demo.html"
401
+ if widget_path.exists():
402
+ return HTMLResponse(content=widget_path.read_text())
403
+ else:
404
+ raise HTTPException(
405
+ status_code=404, detail="Presence widget demo not found"
406
+ )
407
+
383
408
  # ========== ACTIVITY FEED ENDPOINTS ==========
384
409
 
385
410
  @app.get("/views/activity-feed", response_class=HTMLResponse)
@@ -2480,6 +2505,115 @@ def get_app(db_path: str) -> FastAPI:
2480
2505
  logger.error(f"WebSocket error: {e}")
2481
2506
  await websocket.close(code=1011)
2482
2507
 
2508
+ # ========================================================================
2509
+ # PRESENCE TRACKING API - Phase 1: Cross-Agent Presence
2510
+ # ========================================================================
2511
+
2512
+ @app.get("/api/presence")
2513
+ async def get_all_presence() -> dict[str, Any]:
2514
+ """
2515
+ Get current presence state for all agents.
2516
+
2517
+ Returns:
2518
+ Dictionary with agents list and timestamp
2519
+ """
2520
+ try:
2521
+ from htmlgraph.api.presence import PresenceManager
2522
+
2523
+ presence_mgr = PresenceManager(db_path=db_path)
2524
+ agents = presence_mgr.get_all_presence()
2525
+
2526
+ return {
2527
+ "agents": [agent.to_dict() for agent in agents],
2528
+ "timestamp": datetime.now().isoformat(),
2529
+ }
2530
+ except Exception as e:
2531
+ logger.error(f"Error getting presence: {e}")
2532
+ return {"agents": [], "timestamp": datetime.now().isoformat()}
2533
+
2534
+ @app.get("/api/presence/{agent_id}")
2535
+ async def get_agent_presence(agent_id: str) -> dict[str, Any]:
2536
+ """
2537
+ Get presence for specific agent.
2538
+
2539
+ Args:
2540
+ agent_id: Agent identifier
2541
+
2542
+ Returns:
2543
+ Agent presence dictionary
2544
+ """
2545
+ try:
2546
+ from htmlgraph.api.presence import PresenceManager
2547
+
2548
+ presence_mgr = PresenceManager(db_path=db_path)
2549
+ presence = presence_mgr.get_agent_presence(agent_id)
2550
+
2551
+ if not presence:
2552
+ raise HTTPException(status_code=404, detail="Agent not found")
2553
+
2554
+ return presence.to_dict()
2555
+ except HTTPException:
2556
+ raise
2557
+ except Exception as e:
2558
+ logger.error(f"Error getting agent presence: {e}")
2559
+ raise HTTPException(status_code=500, detail=str(e))
2560
+
2561
+ @app.websocket("/ws/presence")
2562
+ async def websocket_presence(websocket: WebSocket) -> None:
2563
+ """
2564
+ WebSocket endpoint for real-time agent presence updates.
2565
+
2566
+ Clients subscribe to receive:
2567
+ - Initial presence state for all agents
2568
+ - Updates when agents become active/idle/offline
2569
+ """
2570
+ client_id = f"presence-{int(time.time() * 1000)}"
2571
+
2572
+ try:
2573
+ await websocket.accept()
2574
+ logger.info(f"Presence WebSocket client connected: {client_id}")
2575
+
2576
+ # Send initial presence state
2577
+ from htmlgraph.api.presence import PresenceManager
2578
+
2579
+ presence_mgr = PresenceManager(db_path=db_path)
2580
+ initial_presence = presence_mgr.get_all_presence()
2581
+
2582
+ await websocket.send_json(
2583
+ {
2584
+ "type": "presence_state",
2585
+ "agents": [p.to_dict() for p in initial_presence],
2586
+ "timestamp": datetime.now().isoformat(),
2587
+ }
2588
+ )
2589
+
2590
+ # Poll for presence updates
2591
+ poll_interval = 1.0 # Check every second
2592
+
2593
+ while True:
2594
+ await asyncio.sleep(poll_interval)
2595
+
2596
+ # Get current presence
2597
+ current_presence = presence_mgr.get_all_presence()
2598
+
2599
+ # Send update (client will diff against previous state)
2600
+ await websocket.send_json(
2601
+ {
2602
+ "type": "presence_update",
2603
+ "agents": [p.to_dict() for p in current_presence],
2604
+ "timestamp": datetime.now().isoformat(),
2605
+ }
2606
+ )
2607
+
2608
+ except WebSocketDisconnect:
2609
+ logger.info(f"Presence WebSocket client disconnected: {client_id}")
2610
+ except Exception as e:
2611
+ logger.error(f"Presence WebSocket error: {e}")
2612
+ try:
2613
+ await websocket.close(code=1011)
2614
+ except Exception:
2615
+ pass
2616
+
2483
2617
  return app
2484
2618
 
2485
2619