htmlgraph 0.27.6__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 (31) hide show
  1. htmlgraph/__init__.py +9 -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/session_context.py +1669 -0
  20. htmlgraph/session_manager.py +70 -0
  21. htmlgraph/sync/__init__.py +21 -0
  22. htmlgraph/sync/git_sync.py +458 -0
  23. {htmlgraph-0.27.6.dist-info → htmlgraph-0.28.0.dist-info}/METADATA +1 -1
  24. {htmlgraph-0.27.6.dist-info → htmlgraph-0.28.0.dist-info}/RECORD +31 -16
  25. {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/dashboard.html +0 -0
  26. {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/styles.css +0 -0
  27. {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  28. {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  29. {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  30. {htmlgraph-0.27.6.dist-info → htmlgraph-0.28.0.dist-info}/WHEEL +0 -0
  31. {htmlgraph-0.27.6.dist-info → htmlgraph-0.28.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,393 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Cross-Session Broadcast Demo</title>
7
+ <style>
8
+ body {
9
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
10
+ max-width: 1200px;
11
+ margin: 0 auto;
12
+ padding: 20px;
13
+ background: #f5f5f5;
14
+ }
15
+
16
+ .container {
17
+ display: grid;
18
+ grid-template-columns: 1fr 1fr;
19
+ gap: 20px;
20
+ }
21
+
22
+ .panel {
23
+ background: white;
24
+ border-radius: 8px;
25
+ padding: 20px;
26
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
27
+ }
28
+
29
+ h2 {
30
+ margin-top: 0;
31
+ color: #333;
32
+ }
33
+
34
+ .status {
35
+ display: inline-block;
36
+ padding: 4px 12px;
37
+ border-radius: 12px;
38
+ font-size: 12px;
39
+ font-weight: 600;
40
+ margin-left: 10px;
41
+ }
42
+
43
+ .status.connected {
44
+ background: #10b981;
45
+ color: white;
46
+ }
47
+
48
+ .status.disconnected {
49
+ background: #ef4444;
50
+ color: white;
51
+ }
52
+
53
+ .event {
54
+ padding: 12px;
55
+ margin: 8px 0;
56
+ border-radius: 4px;
57
+ border-left: 4px solid #3b82f6;
58
+ background: #eff6ff;
59
+ animation: slideIn 0.3s ease;
60
+ }
61
+
62
+ @keyframes slideIn {
63
+ from {
64
+ transform: translateX(-20px);
65
+ opacity: 0;
66
+ }
67
+ to {
68
+ transform: translateX(0);
69
+ opacity: 1;
70
+ }
71
+ }
72
+
73
+ .event-type {
74
+ font-weight: 600;
75
+ color: #1e40af;
76
+ margin-bottom: 4px;
77
+ }
78
+
79
+ .event-details {
80
+ font-size: 14px;
81
+ color: #64748b;
82
+ }
83
+
84
+ .timestamp {
85
+ font-size: 12px;
86
+ color: #94a3b8;
87
+ margin-top: 4px;
88
+ }
89
+
90
+ .feature-card {
91
+ padding: 16px;
92
+ margin: 12px 0;
93
+ border-radius: 8px;
94
+ border: 1px solid #e2e8f0;
95
+ background: white;
96
+ transition: all 0.3s ease;
97
+ }
98
+
99
+ .feature-card.updated {
100
+ border-color: #10b981;
101
+ background: #ecfdf5;
102
+ animation: pulse 0.5s ease;
103
+ }
104
+
105
+ @keyframes pulse {
106
+ 0%, 100% { transform: scale(1); }
107
+ 50% { transform: scale(1.02); }
108
+ }
109
+
110
+ .feature-title {
111
+ font-weight: 600;
112
+ margin-bottom: 8px;
113
+ }
114
+
115
+ .feature-status {
116
+ display: inline-block;
117
+ padding: 4px 8px;
118
+ border-radius: 4px;
119
+ font-size: 12px;
120
+ font-weight: 500;
121
+ }
122
+
123
+ .feature-status.todo { background: #e0e7ff; color: #4338ca; }
124
+ .feature-status.in_progress { background: #fef3c7; color: #92400e; }
125
+ .feature-status.done { background: #d1fae5; color: #065f46; }
126
+ .feature-status.blocked { background: #fee2e2; color: #991b1b; }
127
+
128
+ button {
129
+ padding: 8px 16px;
130
+ background: #3b82f6;
131
+ color: white;
132
+ border: none;
133
+ border-radius: 4px;
134
+ cursor: pointer;
135
+ font-size: 14px;
136
+ margin: 4px;
137
+ }
138
+
139
+ button:hover {
140
+ background: #2563eb;
141
+ }
142
+
143
+ .controls {
144
+ margin: 20px 0;
145
+ padding: 15px;
146
+ background: #f8fafc;
147
+ border-radius: 4px;
148
+ }
149
+
150
+ .notification {
151
+ position: fixed;
152
+ top: 20px;
153
+ right: 20px;
154
+ background: #10b981;
155
+ color: white;
156
+ padding: 16px 24px;
157
+ border-radius: 8px;
158
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
159
+ animation: slideInRight 0.3s ease;
160
+ z-index: 1000;
161
+ }
162
+
163
+ @keyframes slideInRight {
164
+ from {
165
+ transform: translateX(400px);
166
+ opacity: 0;
167
+ }
168
+ to {
169
+ transform: translateX(0);
170
+ opacity: 1;
171
+ }
172
+ }
173
+
174
+ .event-feed {
175
+ max-height: 500px;
176
+ overflow-y: auto;
177
+ }
178
+ </style>
179
+ </head>
180
+ <body>
181
+ <h1>Cross-Session Broadcast Demo</h1>
182
+ <p>Demonstrates real-time feature updates across multiple sessions</p>
183
+
184
+ <div class="controls">
185
+ <strong>Simulate Updates:</strong>
186
+ <button onclick="simulateFeatureUpdate()">Update Feature</button>
187
+ <button onclick="simulateStatusChange()">Change Status</button>
188
+ <button onclick="simulateLinkAdd()">Add Link</button>
189
+ <button onclick="simulateCreate()">Create Feature</button>
190
+ </div>
191
+
192
+ <div class="container">
193
+ <div class="panel">
194
+ <h2>
195
+ Live Features
196
+ <span class="status" id="ws-status">Disconnected</span>
197
+ </h2>
198
+ <div id="features"></div>
199
+ </div>
200
+
201
+ <div class="panel">
202
+ <h2>Broadcast Events</h2>
203
+ <div class="event-feed" id="event-feed"></div>
204
+ </div>
205
+ </div>
206
+
207
+ <script>
208
+ let ws = null;
209
+ let features = new Map();
210
+ const maxEvents = 20;
211
+
212
+ // Initialize features
213
+ features.set('feat-123', { title: 'User Authentication', status: 'in_progress' });
214
+ features.set('feat-456', { title: 'Dashboard API', status: 'todo' });
215
+ features.set('feat-789', { title: 'WebSocket Support', status: 'done' });
216
+
217
+ function connectWebSocket() {
218
+ // Connect to broadcast WebSocket endpoint
219
+ ws = new WebSocket('ws://localhost:8000/ws/broadcasts');
220
+
221
+ ws.onopen = () => {
222
+ console.log('WebSocket connected');
223
+ document.getElementById('ws-status').className = 'status connected';
224
+ document.getElementById('ws-status').textContent = 'Connected';
225
+
226
+ // Send subscribe message
227
+ ws.send('subscribe');
228
+ };
229
+
230
+ ws.onmessage = (event) => {
231
+ const msg = JSON.parse(event.data);
232
+ console.log('Received message:', msg);
233
+
234
+ if (msg.type === 'broadcast_event') {
235
+ handleBroadcastEvent(msg);
236
+ } else if (msg.type === 'subscribed') {
237
+ addEventToFeed('info', 'Connected to broadcast channel', msg.message);
238
+ }
239
+ };
240
+
241
+ ws.onclose = () => {
242
+ console.log('WebSocket disconnected');
243
+ document.getElementById('ws-status').className = 'status disconnected';
244
+ document.getElementById('ws-status').textContent = 'Disconnected';
245
+
246
+ // Reconnect after 3 seconds
247
+ setTimeout(connectWebSocket, 3000);
248
+ };
249
+
250
+ ws.onerror = (error) => {
251
+ console.error('WebSocket error:', error);
252
+ };
253
+ }
254
+
255
+ function handleBroadcastEvent(msg) {
256
+ const eventType = msg.event_type;
257
+ const resourceId = msg.resource_id;
258
+ const agentId = msg.agent_id;
259
+ const payload = msg.payload;
260
+
261
+ console.log(`Broadcast: ${eventType} for ${resourceId} by ${agentId}`);
262
+
263
+ // Update UI based on event type
264
+ if (eventType === 'feature_updated') {
265
+ updateFeature(resourceId, payload);
266
+ showNotification(`Feature ${resourceId} updated by ${agentId}`);
267
+ addEventToFeed('update', `Feature Updated: ${resourceId}`,
268
+ `Agent: ${agentId}, Changes: ${JSON.stringify(payload)}`);
269
+ } else if (eventType === 'feature_created') {
270
+ createFeature(resourceId, payload);
271
+ showNotification(`New feature ${resourceId} created by ${agentId}`);
272
+ addEventToFeed('create', `Feature Created: ${resourceId}`,
273
+ `Agent: ${agentId}, Title: ${payload.title}`);
274
+ } else if (eventType === 'status_changed') {
275
+ updateFeatureStatus(resourceId, payload.new_status);
276
+ showNotification(`Status changed: ${payload.old_status} → ${payload.new_status}`);
277
+ addEventToFeed('status', `Status Changed: ${resourceId}`,
278
+ `${payload.old_status} → ${payload.new_status} by ${agentId}`);
279
+ } else if (eventType === 'link_added') {
280
+ showNotification(`Link added: ${resourceId} → ${payload.linked_feature_id}`);
281
+ addEventToFeed('link', `Link Added`,
282
+ `${resourceId} ${payload.link_type} ${payload.linked_feature_id}`);
283
+ }
284
+
285
+ renderFeatures();
286
+ }
287
+
288
+ function updateFeature(featureId, payload) {
289
+ if (!features.has(featureId)) {
290
+ features.set(featureId, {});
291
+ }
292
+
293
+ const feature = features.get(featureId);
294
+ Object.assign(feature, payload);
295
+
296
+ // Highlight updated card
297
+ setTimeout(() => {
298
+ const card = document.querySelector(`[data-feature-id="${featureId}"]`);
299
+ if (card) {
300
+ card.classList.add('updated');
301
+ setTimeout(() => card.classList.remove('updated'), 1000);
302
+ }
303
+ }, 100);
304
+ }
305
+
306
+ function createFeature(featureId, payload) {
307
+ features.set(featureId, payload);
308
+ }
309
+
310
+ function updateFeatureStatus(featureId, newStatus) {
311
+ if (features.has(featureId)) {
312
+ features.get(featureId).status = newStatus;
313
+ }
314
+ }
315
+
316
+ function renderFeatures() {
317
+ const container = document.getElementById('features');
318
+ container.innerHTML = '';
319
+
320
+ features.forEach((feature, id) => {
321
+ const card = document.createElement('div');
322
+ card.className = 'feature-card';
323
+ card.setAttribute('data-feature-id', id);
324
+
325
+ card.innerHTML = `
326
+ <div class="feature-title">${feature.title || id}</div>
327
+ <span class="feature-status ${feature.status}">${feature.status || 'todo'}</span>
328
+ `;
329
+
330
+ container.appendChild(card);
331
+ });
332
+ }
333
+
334
+ function addEventToFeed(type, title, details) {
335
+ const feed = document.getElementById('event-feed');
336
+
337
+ const event = document.createElement('div');
338
+ event.className = 'event';
339
+ event.innerHTML = `
340
+ <div class="event-type">${title}</div>
341
+ <div class="event-details">${details}</div>
342
+ <div class="timestamp">${new Date().toLocaleTimeString()}</div>
343
+ `;
344
+
345
+ feed.insertBefore(event, feed.firstChild);
346
+
347
+ // Keep only last N events
348
+ while (feed.children.length > maxEvents) {
349
+ feed.removeChild(feed.lastChild);
350
+ }
351
+ }
352
+
353
+ function showNotification(message) {
354
+ const notification = document.createElement('div');
355
+ notification.className = 'notification';
356
+ notification.textContent = message;
357
+ document.body.appendChild(notification);
358
+
359
+ setTimeout(() => notification.remove(), 3000);
360
+ }
361
+
362
+ // Simulation functions for demo
363
+ function simulateFeatureUpdate() {
364
+ const featureId = 'feat-123';
365
+ updateFeature(featureId, { title: 'User Authentication (Updated)', status: 'in_progress' });
366
+ addEventToFeed('update', 'Simulated Feature Update', `Updated ${featureId}`);
367
+ renderFeatures();
368
+ }
369
+
370
+ function simulateStatusChange() {
371
+ const featureId = 'feat-456';
372
+ updateFeatureStatus(featureId, 'in_progress');
373
+ addEventToFeed('status', 'Simulated Status Change', `${featureId}: todo → in_progress`);
374
+ renderFeatures();
375
+ }
376
+
377
+ function simulateLinkAdd() {
378
+ addEventToFeed('link', 'Simulated Link Add', 'feat-123 depends_on feat-456');
379
+ }
380
+
381
+ function simulateCreate() {
382
+ const newId = `feat-${Math.random().toString(36).substr(2, 9)}`;
383
+ createFeature(newId, { title: 'New Feature', status: 'todo' });
384
+ addEventToFeed('create', 'Simulated Feature Create', `Created ${newId}`);
385
+ renderFeatures();
386
+ }
387
+
388
+ // Initialize
389
+ renderFeatures();
390
+ connectWebSocket();
391
+ </script>
392
+ </body>
393
+ </html>
@@ -0,0 +1,184 @@
1
+ """
2
+ API routes for Git sync management.
3
+
4
+ Provides REST endpoints for manual sync triggers, status queries, and configuration.
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ from fastapi import APIRouter, HTTPException
10
+
11
+ from htmlgraph.sync import GitSyncManager, SyncStrategy
12
+
13
+ router = APIRouter(prefix="/api/sync", tags=["sync"])
14
+
15
+ # Global sync manager (initialized by server startup)
16
+ sync_manager: GitSyncManager | None = None
17
+
18
+
19
+ def init_sync_manager(manager: GitSyncManager) -> None:
20
+ """Initialize the global sync manager."""
21
+ global sync_manager
22
+ sync_manager = manager
23
+
24
+
25
+ @router.post("/push")
26
+ async def trigger_push(force: bool = False) -> dict[str, Any]:
27
+ """
28
+ Manually trigger push to remote.
29
+
30
+ Args:
31
+ force: Force push even if recently pushed
32
+
33
+ Returns:
34
+ Sync result dictionary
35
+
36
+ Raises:
37
+ HTTPException: If sync manager not initialized
38
+ """
39
+ if sync_manager is None:
40
+ raise HTTPException(status_code=503, detail="Sync manager not initialized")
41
+
42
+ result = await sync_manager.push(force=force)
43
+ return result.to_dict()
44
+
45
+
46
+ @router.post("/pull")
47
+ async def trigger_pull(force: bool = False) -> dict[str, Any]:
48
+ """
49
+ Manually trigger pull from remote.
50
+
51
+ Args:
52
+ force: Force pull even if recently pulled
53
+
54
+ Returns:
55
+ Sync result dictionary
56
+
57
+ Raises:
58
+ HTTPException: If sync manager not initialized
59
+ """
60
+ if sync_manager is None:
61
+ raise HTTPException(status_code=503, detail="Sync manager not initialized")
62
+
63
+ result = await sync_manager.pull(force=force)
64
+ return result.to_dict()
65
+
66
+
67
+ @router.get("/status")
68
+ async def get_sync_status() -> dict[str, Any]:
69
+ """
70
+ Get current sync status.
71
+
72
+ Returns:
73
+ Status dictionary with sync state and config
74
+
75
+ Raises:
76
+ HTTPException: If sync manager not initialized
77
+ """
78
+ if sync_manager is None:
79
+ raise HTTPException(status_code=503, detail="Sync manager not initialized")
80
+
81
+ return sync_manager.get_status()
82
+
83
+
84
+ @router.get("/history")
85
+ async def get_sync_history(limit: int = 50) -> dict[str, Any]:
86
+ """
87
+ Get sync operation history.
88
+
89
+ Args:
90
+ limit: Maximum number of results
91
+
92
+ Returns:
93
+ Dictionary with history list
94
+
95
+ Raises:
96
+ HTTPException: If sync manager not initialized
97
+ """
98
+ if sync_manager is None:
99
+ raise HTTPException(status_code=503, detail="Sync manager not initialized")
100
+
101
+ return {"history": sync_manager.get_sync_history(limit)}
102
+
103
+
104
+ @router.post("/config")
105
+ async def update_sync_config(
106
+ push_interval: int | None = None,
107
+ pull_interval: int | None = None,
108
+ conflict_strategy: str | None = None,
109
+ ) -> dict[str, Any]:
110
+ """
111
+ Update sync configuration.
112
+
113
+ Args:
114
+ push_interval: Push interval in seconds
115
+ pull_interval: Pull interval in seconds
116
+ conflict_strategy: Conflict resolution strategy
117
+
118
+ Returns:
119
+ Success status and updated config
120
+
121
+ Raises:
122
+ HTTPException: If sync manager not initialized or invalid parameters
123
+ """
124
+ if sync_manager is None:
125
+ raise HTTPException(status_code=503, detail="Sync manager not initialized")
126
+
127
+ try:
128
+ if push_interval is not None:
129
+ if push_interval < 10:
130
+ raise ValueError("Push interval must be >= 10 seconds")
131
+ sync_manager.config.push_interval_seconds = push_interval
132
+
133
+ if pull_interval is not None:
134
+ if pull_interval < 10:
135
+ raise ValueError("Pull interval must be >= 10 seconds")
136
+ sync_manager.config.pull_interval_seconds = pull_interval
137
+
138
+ if conflict_strategy is not None:
139
+ sync_manager.config.conflict_strategy = SyncStrategy(conflict_strategy)
140
+
141
+ return {"success": True, "config": sync_manager.get_status()["config"]}
142
+ except ValueError as e:
143
+ raise HTTPException(status_code=400, detail=str(e))
144
+
145
+
146
+ @router.post("/start")
147
+ async def start_background_sync() -> dict[str, Any]:
148
+ """
149
+ Start background sync service.
150
+
151
+ Returns:
152
+ Success status
153
+
154
+ Raises:
155
+ HTTPException: If sync manager not initialized
156
+ """
157
+ if sync_manager is None:
158
+ raise HTTPException(status_code=503, detail="Sync manager not initialized")
159
+
160
+ # Start in background (don't await)
161
+ import asyncio
162
+
163
+ asyncio.create_task(sync_manager.start_background_sync())
164
+
165
+ return {"success": True, "message": "Background sync started"}
166
+
167
+
168
+ @router.post("/stop")
169
+ async def stop_background_sync() -> dict[str, Any]:
170
+ """
171
+ Stop background sync service.
172
+
173
+ Returns:
174
+ Success status
175
+
176
+ Raises:
177
+ HTTPException: If sync manager not initialized
178
+ """
179
+ if sync_manager is None:
180
+ raise HTTPException(status_code=503, detail="Sync manager not initialized")
181
+
182
+ await sync_manager.stop_background_sync()
183
+
184
+ return {"success": True, "message": "Background sync stopped"}