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
htmlgraph/api/websocket.py
CHANGED
|
@@ -90,6 +90,7 @@ class WebSocketClient:
|
|
|
90
90
|
|
|
91
91
|
websocket: WebSocket
|
|
92
92
|
client_id: str
|
|
93
|
+
session_id: str # Session this client belongs to
|
|
93
94
|
subscription_filter: EventSubscriptionFilter
|
|
94
95
|
connected_at: datetime = field(default_factory=datetime.now)
|
|
95
96
|
events_sent: int = 0
|
|
@@ -189,8 +190,9 @@ class WebSocketManager:
|
|
|
189
190
|
self.event_batch_window_ms = event_batch_window_ms
|
|
190
191
|
self.poll_interval_ms = poll_interval_ms / 1000.0 # Convert to seconds
|
|
191
192
|
|
|
192
|
-
# Active connections: {
|
|
193
|
-
|
|
193
|
+
# Active connections: {client_id: WebSocketClient}
|
|
194
|
+
# Flattened structure enables efficient cross-session broadcasting
|
|
195
|
+
self.connections: dict[str, WebSocketClient] = {}
|
|
194
196
|
|
|
195
197
|
# Event batchers per session: {session_id: EventBatcher}
|
|
196
198
|
self.batchers: dict[str, EventBatcher] = {}
|
|
@@ -227,7 +229,7 @@ class WebSocketManager:
|
|
|
227
229
|
await websocket.accept()
|
|
228
230
|
|
|
229
231
|
# Check max clients per session
|
|
230
|
-
session_clients = self.
|
|
232
|
+
session_clients = await self.get_session_clients(session_id)
|
|
231
233
|
if len(session_clients) >= self.max_clients_per_session:
|
|
232
234
|
logger.warning(
|
|
233
235
|
f"Session {session_id} has max clients ({self.max_clients_per_session})"
|
|
@@ -243,13 +245,12 @@ class WebSocketManager:
|
|
|
243
245
|
client = WebSocketClient(
|
|
244
246
|
websocket=websocket,
|
|
245
247
|
client_id=client_id,
|
|
248
|
+
session_id=session_id,
|
|
246
249
|
subscription_filter=subscription_filter,
|
|
247
250
|
)
|
|
248
251
|
|
|
249
|
-
# Add to connections
|
|
250
|
-
|
|
251
|
-
self.connections[session_id] = {}
|
|
252
|
-
self.connections[session_id][client_id] = client
|
|
252
|
+
# Add to flat connections structure
|
|
253
|
+
self.connections[client_id] = client
|
|
253
254
|
|
|
254
255
|
# Create batcher for session if needed
|
|
255
256
|
if session_id not in self.batchers:
|
|
@@ -260,7 +261,8 @@ class WebSocketManager:
|
|
|
260
261
|
|
|
261
262
|
# Update metrics
|
|
262
263
|
self.metrics["total_connections"] += 1
|
|
263
|
-
|
|
264
|
+
active_sessions = await self.get_active_sessions()
|
|
265
|
+
self.metrics["active_sessions"] = len(active_sessions)
|
|
264
266
|
|
|
265
267
|
logger.info(
|
|
266
268
|
f"WebSocket client connected: session={session_id}, client={client_id}"
|
|
@@ -279,24 +281,48 @@ class WebSocketManager:
|
|
|
279
281
|
session_id: Session ID
|
|
280
282
|
client_id: Client ID to disconnect
|
|
281
283
|
"""
|
|
282
|
-
if
|
|
284
|
+
if client_id not in self.connections:
|
|
283
285
|
return
|
|
284
286
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
del self.connections[session_id][client_id]
|
|
287
|
+
client = self.connections[client_id]
|
|
288
|
+
del self.connections[client_id]
|
|
288
289
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
290
|
+
# Check if this was the last client for the session
|
|
291
|
+
session_clients = await self.get_session_clients(session_id)
|
|
292
|
+
if not session_clients:
|
|
293
|
+
# Clean up session batcher
|
|
294
|
+
if session_id in self.batchers:
|
|
295
|
+
del self.batchers[session_id]
|
|
294
296
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
297
|
+
# Update metrics
|
|
298
|
+
active_sessions = await self.get_active_sessions()
|
|
299
|
+
self.metrics["active_sessions"] = len(active_sessions)
|
|
300
|
+
|
|
301
|
+
logger.info(
|
|
302
|
+
f"WebSocket client disconnected: session={session_id}, client={client_id}, "
|
|
303
|
+
f"events_sent={client.events_sent}"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
async def get_session_clients(self, session_id: str) -> list[WebSocketClient]:
|
|
307
|
+
"""
|
|
308
|
+
Get all clients for a specific session.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
session_id: Session ID to filter by
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
List of clients belonging to the session
|
|
315
|
+
"""
|
|
316
|
+
return [c for c in self.connections.values() if c.session_id == session_id]
|
|
317
|
+
|
|
318
|
+
async def get_active_sessions(self) -> set[str]:
|
|
319
|
+
"""
|
|
320
|
+
Get all active session IDs.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Set of session IDs with active connections
|
|
324
|
+
"""
|
|
325
|
+
return {c.session_id for c in self.connections.values()}
|
|
300
326
|
|
|
301
327
|
async def stream_events(
|
|
302
328
|
self,
|
|
@@ -318,14 +344,11 @@ class WebSocketManager:
|
|
|
318
344
|
client_id: Client ID
|
|
319
345
|
get_db: Async function to get database connection
|
|
320
346
|
"""
|
|
321
|
-
if
|
|
322
|
-
|
|
323
|
-
or client_id not in self.connections[session_id]
|
|
324
|
-
):
|
|
325
|
-
logger.warning(f"Client not found: {session_id}/{client_id}")
|
|
347
|
+
if client_id not in self.connections:
|
|
348
|
+
logger.warning(f"Client not found: {client_id}")
|
|
326
349
|
return
|
|
327
350
|
|
|
328
|
-
client = self.connections[
|
|
351
|
+
client = self.connections[client_id]
|
|
329
352
|
last_timestamp = datetime.now().isoformat()
|
|
330
353
|
poll_interval = self.poll_interval_ms
|
|
331
354
|
consecutive_empty_polls = 0
|
|
@@ -487,11 +510,21 @@ class WebSocketManager:
|
|
|
487
510
|
Returns:
|
|
488
511
|
Number of clients that received the event
|
|
489
512
|
"""
|
|
490
|
-
|
|
491
|
-
|
|
513
|
+
return await self.broadcast_to_session(session_id, event)
|
|
514
|
+
|
|
515
|
+
async def broadcast_to_session(self, session_id: str, event: dict[str, Any]) -> int:
|
|
516
|
+
"""
|
|
517
|
+
Broadcast event to all clients in a specific session.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
session_id: Session to broadcast to
|
|
521
|
+
event: Event data
|
|
492
522
|
|
|
523
|
+
Returns:
|
|
524
|
+
Number of clients that received the event
|
|
525
|
+
"""
|
|
493
526
|
sent_count = 0
|
|
494
|
-
session_clients =
|
|
527
|
+
session_clients = await self.get_session_clients(session_id)
|
|
495
528
|
|
|
496
529
|
for client in session_clients:
|
|
497
530
|
if client.subscription_filter.matches_event(event):
|
|
@@ -510,12 +543,56 @@ class WebSocketManager:
|
|
|
510
543
|
|
|
511
544
|
return sent_count
|
|
512
545
|
|
|
546
|
+
async def broadcast_to_all_sessions(self, event: dict[str, Any]) -> int:
|
|
547
|
+
"""
|
|
548
|
+
Broadcast event to all connected clients across all sessions.
|
|
549
|
+
|
|
550
|
+
Enables cross-session features like:
|
|
551
|
+
- Global notifications
|
|
552
|
+
- System-wide alerts
|
|
553
|
+
- Cross-agent coordination
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
event: Event data to broadcast
|
|
557
|
+
|
|
558
|
+
Returns:
|
|
559
|
+
Number of clients that received the event
|
|
560
|
+
"""
|
|
561
|
+
sent_count = 0
|
|
562
|
+
|
|
563
|
+
for client in list(self.connections.values()):
|
|
564
|
+
if client.subscription_filter.matches_event(event):
|
|
565
|
+
try:
|
|
566
|
+
await client.websocket.send_json(
|
|
567
|
+
{
|
|
568
|
+
"type": "event",
|
|
569
|
+
"timestamp": datetime.now().isoformat(),
|
|
570
|
+
**event,
|
|
571
|
+
}
|
|
572
|
+
)
|
|
573
|
+
sent_count += 1
|
|
574
|
+
client.events_sent += 1
|
|
575
|
+
except Exception as e:
|
|
576
|
+
logger.error(f"Broadcast error to {client.client_id}: {e}")
|
|
577
|
+
|
|
578
|
+
return sent_count
|
|
579
|
+
|
|
513
580
|
def get_session_metrics(self, session_id: str) -> dict[str, Any]:
|
|
514
581
|
"""Get metrics for a session."""
|
|
515
|
-
|
|
582
|
+
# Use asyncio.run to call async method in sync context
|
|
583
|
+
import asyncio
|
|
584
|
+
|
|
585
|
+
try:
|
|
586
|
+
loop = asyncio.get_event_loop()
|
|
587
|
+
except RuntimeError:
|
|
588
|
+
loop = asyncio.new_event_loop()
|
|
589
|
+
asyncio.set_event_loop(loop)
|
|
590
|
+
|
|
591
|
+
clients = loop.run_until_complete(self.get_session_clients(session_id))
|
|
592
|
+
|
|
593
|
+
if not clients:
|
|
516
594
|
return {}
|
|
517
595
|
|
|
518
|
-
clients = self.connections[session_id].values()
|
|
519
596
|
return {
|
|
520
597
|
"session_id": session_id,
|
|
521
598
|
"connected_clients": len(clients),
|
|
@@ -531,8 +608,6 @@ class WebSocketManager:
|
|
|
531
608
|
"""Get global WebSocket metrics."""
|
|
532
609
|
return {
|
|
533
610
|
**self.metrics,
|
|
534
|
-
"active_sessions":
|
|
535
|
-
"total_connected_clients":
|
|
536
|
-
len(clients) for clients in self.connections.values()
|
|
537
|
-
),
|
|
611
|
+
"active_sessions": self.metrics["active_sessions"],
|
|
612
|
+
"total_connected_clients": len(self.connections),
|
|
538
613
|
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Broadcast Integration Helper - SDK to Broadcast Bridge
|
|
3
|
+
|
|
4
|
+
Provides helper functions to integrate broadcasting with SDK operations.
|
|
5
|
+
Handles the bridge between synchronous SDK calls and async broadcast operations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_or_create_event_loop() -> asyncio.AbstractEventLoop:
|
|
16
|
+
"""Get existing event loop or create new one if none exists."""
|
|
17
|
+
try:
|
|
18
|
+
return asyncio.get_running_loop()
|
|
19
|
+
except RuntimeError:
|
|
20
|
+
# No event loop running, create a new one
|
|
21
|
+
try:
|
|
22
|
+
loop = asyncio.get_event_loop()
|
|
23
|
+
if loop.is_closed():
|
|
24
|
+
loop = asyncio.new_event_loop()
|
|
25
|
+
asyncio.set_event_loop(loop)
|
|
26
|
+
return loop
|
|
27
|
+
except RuntimeError:
|
|
28
|
+
loop = asyncio.new_event_loop()
|
|
29
|
+
asyncio.set_event_loop(loop)
|
|
30
|
+
return loop
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def broadcast_feature_save(
|
|
34
|
+
feature_id: str,
|
|
35
|
+
agent_id: str,
|
|
36
|
+
session_id: str,
|
|
37
|
+
payload: dict[str, Any],
|
|
38
|
+
is_new: bool = False,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""
|
|
41
|
+
Broadcast feature save operation to all sessions.
|
|
42
|
+
|
|
43
|
+
This is a synchronous wrapper that can be called from SDK save() methods.
|
|
44
|
+
It handles async event loop creation and broadcasting.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
feature_id: Feature being saved
|
|
48
|
+
agent_id: Agent making the change
|
|
49
|
+
session_id: Source session ID
|
|
50
|
+
payload: Feature data (title, status, description, etc.)
|
|
51
|
+
is_new: True if creating new feature, False if updating
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
# Import here to avoid circular dependencies
|
|
55
|
+
from pathlib import Path
|
|
56
|
+
|
|
57
|
+
from htmlgraph.api.broadcast import CrossSessionBroadcaster
|
|
58
|
+
from htmlgraph.api.websocket import WebSocketManager
|
|
59
|
+
|
|
60
|
+
# Get database path
|
|
61
|
+
db_path = str(Path.home() / ".htmlgraph" / "htmlgraph.db")
|
|
62
|
+
|
|
63
|
+
# Create WebSocketManager and Broadcaster instances
|
|
64
|
+
# Note: These are lightweight, stateless for broadcasting
|
|
65
|
+
websocket_manager = WebSocketManager(db_path)
|
|
66
|
+
broadcaster = CrossSessionBroadcaster(websocket_manager, db_path)
|
|
67
|
+
|
|
68
|
+
# Determine event type
|
|
69
|
+
event_type = "created" if is_new else "updated"
|
|
70
|
+
payload["event_type"] = event_type
|
|
71
|
+
|
|
72
|
+
# Run async broadcast in event loop
|
|
73
|
+
loop = get_or_create_event_loop()
|
|
74
|
+
|
|
75
|
+
if loop.is_running():
|
|
76
|
+
# We're already in an async context, schedule task
|
|
77
|
+
asyncio.create_task(
|
|
78
|
+
broadcaster.broadcast_feature_update(
|
|
79
|
+
feature_id=feature_id,
|
|
80
|
+
agent_id=agent_id,
|
|
81
|
+
session_id=session_id,
|
|
82
|
+
payload=payload,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
# Don't wait for completion to avoid blocking
|
|
86
|
+
logger.debug(
|
|
87
|
+
f"Scheduled broadcast task for feature {feature_id} (async context)"
|
|
88
|
+
)
|
|
89
|
+
else:
|
|
90
|
+
# Not in async context, run until complete
|
|
91
|
+
clients_notified = loop.run_until_complete(
|
|
92
|
+
broadcaster.broadcast_feature_update(
|
|
93
|
+
feature_id=feature_id,
|
|
94
|
+
agent_id=agent_id,
|
|
95
|
+
session_id=session_id,
|
|
96
|
+
payload=payload,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
logger.info(
|
|
100
|
+
f"Broadcast feature {feature_id} {event_type} to {clients_notified} clients"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
except Exception as e:
|
|
104
|
+
# Never fail the save due to broadcast error
|
|
105
|
+
logger.warning(f"Failed to broadcast feature save: {e}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def broadcast_status_change(
|
|
109
|
+
feature_id: str,
|
|
110
|
+
old_status: str,
|
|
111
|
+
new_status: str,
|
|
112
|
+
agent_id: str,
|
|
113
|
+
session_id: str,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""
|
|
116
|
+
Broadcast feature status change to all sessions.
|
|
117
|
+
|
|
118
|
+
Synchronous wrapper for async broadcast operation.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
feature_id: Feature being updated
|
|
122
|
+
old_status: Previous status
|
|
123
|
+
new_status: New status
|
|
124
|
+
agent_id: Agent making change
|
|
125
|
+
session_id: Source session
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
from pathlib import Path
|
|
129
|
+
|
|
130
|
+
from htmlgraph.api.broadcast import CrossSessionBroadcaster
|
|
131
|
+
from htmlgraph.api.websocket import WebSocketManager
|
|
132
|
+
|
|
133
|
+
db_path = str(Path.home() / ".htmlgraph" / "htmlgraph.db")
|
|
134
|
+
websocket_manager = WebSocketManager(db_path)
|
|
135
|
+
broadcaster = CrossSessionBroadcaster(websocket_manager, db_path)
|
|
136
|
+
|
|
137
|
+
loop = get_or_create_event_loop()
|
|
138
|
+
|
|
139
|
+
if loop.is_running():
|
|
140
|
+
asyncio.create_task(
|
|
141
|
+
broadcaster.broadcast_status_change(
|
|
142
|
+
feature_id=feature_id,
|
|
143
|
+
old_status=old_status,
|
|
144
|
+
new_status=new_status,
|
|
145
|
+
agent_id=agent_id,
|
|
146
|
+
session_id=session_id,
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
logger.debug(f"Scheduled status change broadcast for {feature_id}")
|
|
150
|
+
else:
|
|
151
|
+
clients_notified = loop.run_until_complete(
|
|
152
|
+
broadcaster.broadcast_status_change(
|
|
153
|
+
feature_id=feature_id,
|
|
154
|
+
old_status=old_status,
|
|
155
|
+
new_status=new_status,
|
|
156
|
+
agent_id=agent_id,
|
|
157
|
+
session_id=session_id,
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
logger.info(
|
|
161
|
+
f"Broadcast status change for {feature_id}: {old_status} → {new_status} "
|
|
162
|
+
f"to {clients_notified} clients"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
except Exception as e:
|
|
166
|
+
logger.warning(f"Failed to broadcast status change: {e}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def broadcast_link_added(
|
|
170
|
+
feature_id: str,
|
|
171
|
+
linked_feature_id: str,
|
|
172
|
+
link_type: str,
|
|
173
|
+
agent_id: str,
|
|
174
|
+
session_id: str,
|
|
175
|
+
) -> None:
|
|
176
|
+
"""
|
|
177
|
+
Broadcast feature link addition to all sessions.
|
|
178
|
+
|
|
179
|
+
Synchronous wrapper for async broadcast operation.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
feature_id: Source feature
|
|
183
|
+
linked_feature_id: Target feature
|
|
184
|
+
link_type: Type of relationship
|
|
185
|
+
agent_id: Agent making change
|
|
186
|
+
session_id: Source session
|
|
187
|
+
"""
|
|
188
|
+
try:
|
|
189
|
+
from pathlib import Path
|
|
190
|
+
|
|
191
|
+
from htmlgraph.api.broadcast import CrossSessionBroadcaster
|
|
192
|
+
from htmlgraph.api.websocket import WebSocketManager
|
|
193
|
+
|
|
194
|
+
db_path = str(Path.home() / ".htmlgraph" / "htmlgraph.db")
|
|
195
|
+
websocket_manager = WebSocketManager(db_path)
|
|
196
|
+
broadcaster = CrossSessionBroadcaster(websocket_manager, db_path)
|
|
197
|
+
|
|
198
|
+
loop = get_or_create_event_loop()
|
|
199
|
+
|
|
200
|
+
if loop.is_running():
|
|
201
|
+
asyncio.create_task(
|
|
202
|
+
broadcaster.broadcast_link_added(
|
|
203
|
+
feature_id=feature_id,
|
|
204
|
+
linked_feature_id=linked_feature_id,
|
|
205
|
+
link_type=link_type,
|
|
206
|
+
agent_id=agent_id,
|
|
207
|
+
session_id=session_id,
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
logger.debug(f"Scheduled link broadcast for {feature_id}")
|
|
211
|
+
else:
|
|
212
|
+
clients_notified = loop.run_until_complete(
|
|
213
|
+
broadcaster.broadcast_link_added(
|
|
214
|
+
feature_id=feature_id,
|
|
215
|
+
linked_feature_id=linked_feature_id,
|
|
216
|
+
link_type=link_type,
|
|
217
|
+
agent_id=agent_id,
|
|
218
|
+
session_id=session_id,
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
logger.info(
|
|
222
|
+
f"Broadcast link addition for {feature_id} → {linked_feature_id} "
|
|
223
|
+
f"({link_type}) to {clients_notified} clients"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
except Exception as e:
|
|
227
|
+
logger.warning(f"Failed to broadcast link addition: {e}")
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI commands for Git-based sync management.
|
|
3
|
+
|
|
4
|
+
Provides commands to start/stop background sync, trigger manual operations,
|
|
5
|
+
and check sync status.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from htmlgraph.sync import GitSyncManager, SyncConfig, SyncStrategy
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group()
|
|
17
|
+
def sync() -> None:
|
|
18
|
+
"""Git-based multi-device sync commands."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@sync.command()
|
|
23
|
+
@click.option(
|
|
24
|
+
"--push-interval",
|
|
25
|
+
type=int,
|
|
26
|
+
default=300,
|
|
27
|
+
help="Push interval in seconds (default: 300 = 5 min)",
|
|
28
|
+
)
|
|
29
|
+
@click.option(
|
|
30
|
+
"--pull-interval",
|
|
31
|
+
type=int,
|
|
32
|
+
default=60,
|
|
33
|
+
help="Pull interval in seconds (default: 60 = 1 min)",
|
|
34
|
+
)
|
|
35
|
+
@click.option(
|
|
36
|
+
"--strategy",
|
|
37
|
+
type=click.Choice(["auto_merge", "abort_on_conflict", "ours", "theirs"]),
|
|
38
|
+
default="auto_merge",
|
|
39
|
+
help="Conflict resolution strategy",
|
|
40
|
+
)
|
|
41
|
+
@click.option(
|
|
42
|
+
"--repo-root",
|
|
43
|
+
type=click.Path(exists=True),
|
|
44
|
+
default=".",
|
|
45
|
+
help="Git repository root path",
|
|
46
|
+
)
|
|
47
|
+
def start(
|
|
48
|
+
push_interval: int, pull_interval: int, strategy: str, repo_root: str
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Start background sync service."""
|
|
51
|
+
config = SyncConfig(
|
|
52
|
+
push_interval_seconds=push_interval,
|
|
53
|
+
pull_interval_seconds=pull_interval,
|
|
54
|
+
conflict_strategy=SyncStrategy(strategy),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
manager = GitSyncManager(repo_root, config)
|
|
58
|
+
|
|
59
|
+
click.echo("Starting background sync service...")
|
|
60
|
+
click.echo(f" Push interval: {push_interval}s")
|
|
61
|
+
click.echo(f" Pull interval: {pull_interval}s")
|
|
62
|
+
click.echo(f" Conflict strategy: {strategy}")
|
|
63
|
+
click.echo(f" Repository: {Path(repo_root).absolute()}")
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
asyncio.run(manager.start_background_sync())
|
|
67
|
+
except KeyboardInterrupt:
|
|
68
|
+
click.echo("\nStopping background sync...")
|
|
69
|
+
asyncio.run(manager.stop_background_sync())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@sync.command()
|
|
73
|
+
@click.option(
|
|
74
|
+
"--repo-root",
|
|
75
|
+
type=click.Path(exists=True),
|
|
76
|
+
default=".",
|
|
77
|
+
help="Git repository root path",
|
|
78
|
+
)
|
|
79
|
+
def push(repo_root: str) -> None:
|
|
80
|
+
"""Manually push changes to remote."""
|
|
81
|
+
manager = GitSyncManager(repo_root)
|
|
82
|
+
|
|
83
|
+
click.echo("Pushing changes to remote...")
|
|
84
|
+
|
|
85
|
+
async def do_push() -> None:
|
|
86
|
+
result = await manager.push(force=True)
|
|
87
|
+
click.echo(f"Status: {result.status.value}")
|
|
88
|
+
click.echo(f"Files changed: {result.files_changed}")
|
|
89
|
+
click.echo(f"Message: {result.message}")
|
|
90
|
+
if result.conflicts:
|
|
91
|
+
click.echo(f"Conflicts: {', '.join(result.conflicts)}")
|
|
92
|
+
|
|
93
|
+
asyncio.run(do_push())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@sync.command()
|
|
97
|
+
@click.option(
|
|
98
|
+
"--repo-root",
|
|
99
|
+
type=click.Path(exists=True),
|
|
100
|
+
default=".",
|
|
101
|
+
help="Git repository root path",
|
|
102
|
+
)
|
|
103
|
+
def pull(repo_root: str) -> None:
|
|
104
|
+
"""Manually pull changes from remote."""
|
|
105
|
+
manager = GitSyncManager(repo_root)
|
|
106
|
+
|
|
107
|
+
click.echo("Pulling changes from remote...")
|
|
108
|
+
|
|
109
|
+
async def do_pull() -> None:
|
|
110
|
+
result = await manager.pull(force=True)
|
|
111
|
+
click.echo(f"Status: {result.status.value}")
|
|
112
|
+
click.echo(f"Message: {result.message}")
|
|
113
|
+
if result.conflicts:
|
|
114
|
+
click.echo(f"Conflicts: {', '.join(result.conflicts)}")
|
|
115
|
+
|
|
116
|
+
asyncio.run(do_pull())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@sync.command()
|
|
120
|
+
@click.option(
|
|
121
|
+
"--repo-root",
|
|
122
|
+
type=click.Path(exists=True),
|
|
123
|
+
default=".",
|
|
124
|
+
help="Git repository root path",
|
|
125
|
+
)
|
|
126
|
+
@click.option(
|
|
127
|
+
"--limit",
|
|
128
|
+
type=int,
|
|
129
|
+
default=10,
|
|
130
|
+
help="Number of recent operations to show",
|
|
131
|
+
)
|
|
132
|
+
def status(repo_root: str, limit: int) -> None:
|
|
133
|
+
"""Show sync status and recent history."""
|
|
134
|
+
manager = GitSyncManager(repo_root)
|
|
135
|
+
|
|
136
|
+
status_data = manager.get_status()
|
|
137
|
+
history = manager.get_sync_history(limit)
|
|
138
|
+
|
|
139
|
+
click.echo("Sync Status:")
|
|
140
|
+
click.echo(f" Current status: {status_data['status']}")
|
|
141
|
+
click.echo(f" Last push: {status_data['last_push'] or 'Never'}")
|
|
142
|
+
click.echo(f" Last pull: {status_data['last_pull'] or 'Never'}")
|
|
143
|
+
click.echo("\nConfiguration:")
|
|
144
|
+
click.echo(f" Remote: {status_data['config']['remote']}")
|
|
145
|
+
click.echo(f" Branch: {status_data['config']['branch']}")
|
|
146
|
+
click.echo(f" Push interval: {status_data['config']['push_interval']}s")
|
|
147
|
+
click.echo(f" Pull interval: {status_data['config']['pull_interval']}s")
|
|
148
|
+
click.echo(f" Conflict strategy: {status_data['config']['conflict_strategy']}")
|
|
149
|
+
|
|
150
|
+
if history:
|
|
151
|
+
click.echo(f"\nRecent Operations (last {len(history)}):")
|
|
152
|
+
for entry in history:
|
|
153
|
+
click.echo(
|
|
154
|
+
f" [{entry['operation']}] {entry['status']} - {entry['message']}"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@sync.command()
|
|
159
|
+
@click.option(
|
|
160
|
+
"--repo-root",
|
|
161
|
+
type=click.Path(exists=True),
|
|
162
|
+
default=".",
|
|
163
|
+
help="Git repository root path",
|
|
164
|
+
)
|
|
165
|
+
@click.option(
|
|
166
|
+
"--push-interval",
|
|
167
|
+
type=int,
|
|
168
|
+
help="New push interval in seconds",
|
|
169
|
+
)
|
|
170
|
+
@click.option(
|
|
171
|
+
"--pull-interval",
|
|
172
|
+
type=int,
|
|
173
|
+
help="New pull interval in seconds",
|
|
174
|
+
)
|
|
175
|
+
@click.option(
|
|
176
|
+
"--strategy",
|
|
177
|
+
type=click.Choice(["auto_merge", "abort_on_conflict", "ours", "theirs"]),
|
|
178
|
+
help="New conflict resolution strategy",
|
|
179
|
+
)
|
|
180
|
+
def configure(
|
|
181
|
+
repo_root: str,
|
|
182
|
+
push_interval: int | None,
|
|
183
|
+
pull_interval: int | None,
|
|
184
|
+
strategy: str | None,
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Update sync configuration."""
|
|
187
|
+
manager = GitSyncManager(repo_root)
|
|
188
|
+
|
|
189
|
+
if push_interval:
|
|
190
|
+
manager.config.push_interval_seconds = push_interval
|
|
191
|
+
click.echo(f"Push interval updated to {push_interval}s")
|
|
192
|
+
|
|
193
|
+
if pull_interval:
|
|
194
|
+
manager.config.pull_interval_seconds = pull_interval
|
|
195
|
+
click.echo(f"Pull interval updated to {pull_interval}s")
|
|
196
|
+
|
|
197
|
+
if strategy:
|
|
198
|
+
manager.config.conflict_strategy = SyncStrategy(strategy)
|
|
199
|
+
click.echo(f"Conflict strategy updated to {strategy}")
|
|
200
|
+
|
|
201
|
+
if not any([push_interval, pull_interval, strategy]):
|
|
202
|
+
click.echo("No configuration changes specified")
|
|
203
|
+
click.echo("Use --push-interval, --pull-interval, or --strategy")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
if __name__ == "__main__":
|
|
207
|
+
sync()
|