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,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
@@ -2480,6 +2480,115 @@ def get_app(db_path: str) -> FastAPI:
2480
2480
  logger.error(f"WebSocket error: {e}")
2481
2481
  await websocket.close(code=1011)
2482
2482
 
2483
+ # ========================================================================
2484
+ # PRESENCE TRACKING API - Phase 1: Cross-Agent Presence
2485
+ # ========================================================================
2486
+
2487
+ @app.get("/api/presence")
2488
+ async def get_all_presence() -> dict[str, Any]:
2489
+ """
2490
+ Get current presence state for all agents.
2491
+
2492
+ Returns:
2493
+ Dictionary with agents list and timestamp
2494
+ """
2495
+ try:
2496
+ from htmlgraph.api.presence import PresenceManager
2497
+
2498
+ presence_mgr = PresenceManager(db_path=db_path)
2499
+ agents = presence_mgr.get_all_presence()
2500
+
2501
+ return {
2502
+ "agents": [agent.to_dict() for agent in agents],
2503
+ "timestamp": datetime.now().isoformat(),
2504
+ }
2505
+ except Exception as e:
2506
+ logger.error(f"Error getting presence: {e}")
2507
+ return {"agents": [], "timestamp": datetime.now().isoformat()}
2508
+
2509
+ @app.get("/api/presence/{agent_id}")
2510
+ async def get_agent_presence(agent_id: str) -> dict[str, Any]:
2511
+ """
2512
+ Get presence for specific agent.
2513
+
2514
+ Args:
2515
+ agent_id: Agent identifier
2516
+
2517
+ Returns:
2518
+ Agent presence dictionary
2519
+ """
2520
+ try:
2521
+ from htmlgraph.api.presence import PresenceManager
2522
+
2523
+ presence_mgr = PresenceManager(db_path=db_path)
2524
+ presence = presence_mgr.get_agent_presence(agent_id)
2525
+
2526
+ if not presence:
2527
+ raise HTTPException(status_code=404, detail="Agent not found")
2528
+
2529
+ return presence.to_dict()
2530
+ except HTTPException:
2531
+ raise
2532
+ except Exception as e:
2533
+ logger.error(f"Error getting agent presence: {e}")
2534
+ raise HTTPException(status_code=500, detail=str(e))
2535
+
2536
+ @app.websocket("/ws/presence")
2537
+ async def websocket_presence(websocket: WebSocket) -> None:
2538
+ """
2539
+ WebSocket endpoint for real-time agent presence updates.
2540
+
2541
+ Clients subscribe to receive:
2542
+ - Initial presence state for all agents
2543
+ - Updates when agents become active/idle/offline
2544
+ """
2545
+ client_id = f"presence-{int(time.time() * 1000)}"
2546
+
2547
+ try:
2548
+ await websocket.accept()
2549
+ logger.info(f"Presence WebSocket client connected: {client_id}")
2550
+
2551
+ # Send initial presence state
2552
+ from htmlgraph.api.presence import PresenceManager
2553
+
2554
+ presence_mgr = PresenceManager(db_path=db_path)
2555
+ initial_presence = presence_mgr.get_all_presence()
2556
+
2557
+ await websocket.send_json(
2558
+ {
2559
+ "type": "presence_state",
2560
+ "agents": [p.to_dict() for p in initial_presence],
2561
+ "timestamp": datetime.now().isoformat(),
2562
+ }
2563
+ )
2564
+
2565
+ # Poll for presence updates
2566
+ poll_interval = 1.0 # Check every second
2567
+
2568
+ while True:
2569
+ await asyncio.sleep(poll_interval)
2570
+
2571
+ # Get current presence
2572
+ current_presence = presence_mgr.get_all_presence()
2573
+
2574
+ # Send update (client will diff against previous state)
2575
+ await websocket.send_json(
2576
+ {
2577
+ "type": "presence_update",
2578
+ "agents": [p.to_dict() for p in current_presence],
2579
+ "timestamp": datetime.now().isoformat(),
2580
+ }
2581
+ )
2582
+
2583
+ except WebSocketDisconnect:
2584
+ logger.info(f"Presence WebSocket client disconnected: {client_id}")
2585
+ except Exception as e:
2586
+ logger.error(f"Presence WebSocket error: {e}")
2587
+ try:
2588
+ await websocket.close(code=1011)
2589
+ except Exception:
2590
+ pass
2591
+
2483
2592
  return app
2484
2593
 
2485
2594