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.
- htmlgraph/__init__.py +1 -1
- htmlgraph/api/broadcast.py +316 -0
- htmlgraph/api/broadcast_routes.py +357 -0
- htmlgraph/api/broadcast_websocket.py +115 -0
- htmlgraph/api/cost_alerts_websocket.py +7 -16
- htmlgraph/api/main.py +110 -1
- htmlgraph/api/offline.py +776 -0
- htmlgraph/api/presence.py +446 -0
- htmlgraph/api/reactive.py +455 -0
- htmlgraph/api/reactive_routes.py +195 -0
- htmlgraph/api/static/broadcast-demo.html +393 -0
- htmlgraph/api/sync_routes.py +184 -0
- htmlgraph/api/websocket.py +112 -37
- htmlgraph/broadcast_integration.py +227 -0
- htmlgraph/cli_commands/sync.py +207 -0
- htmlgraph/db/schema.py +214 -0
- htmlgraph/hooks/event_tracker.py +53 -2
- htmlgraph/reactive_integration.py +148 -0
- htmlgraph/sync/__init__.py +21 -0
- htmlgraph/sync/git_sync.py +458 -0
- {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.0.dist-info}/METADATA +1 -1
- {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.0.dist-info}/RECORD +29 -15
- {htmlgraph-0.27.7.data → htmlgraph-0.28.0.data}/data/htmlgraph/dashboard.html +0 -0
- {htmlgraph-0.27.7.data → htmlgraph-0.28.0.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.27.7.data → htmlgraph-0.28.0.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.27.7.data → htmlgraph-0.28.0.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.27.7.data → htmlgraph-0.28.0.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.0.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
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[
|
|
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[
|
|
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[
|
|
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
|
|