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
htmlgraph/__init__.py CHANGED
@@ -123,7 +123,7 @@ from htmlgraph.types import (
123
123
  )
124
124
  from htmlgraph.work_type_utils import infer_work_type, infer_work_type_from_id
125
125
 
126
- __version__ = "0.27.7"
126
+ __version__ = "0.28.1"
127
127
  __all__ = [
128
128
  # Exceptions
129
129
  "HtmlGraphError",
@@ -0,0 +1,316 @@
1
+ """
2
+ Cross-Session Broadcast Sync - Phase 2
3
+
4
+ Enables real-time feature updates across multiple Claude Code sessions.
5
+ Session A updates a feature → immediately visible in Session B without manual git pull.
6
+
7
+ Features:
8
+ - Real-time feature/track/spike updates (<100ms latency)
9
+ - Cross-session coordination
10
+ - Automatic synchronization
11
+ - Audit trail for all changes
12
+ """
13
+
14
+ import logging
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime
17
+ from enum import Enum
18
+ from typing import Any
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class BroadcastEventType(str, Enum):
24
+ """Types of broadcast events for cross-session sync."""
25
+
26
+ FEATURE_UPDATED = "feature_updated"
27
+ FEATURE_CREATED = "feature_created"
28
+ FEATURE_DELETED = "feature_deleted"
29
+ TRACK_UPDATED = "track_updated"
30
+ SPIKE_UPDATED = "spike_updated"
31
+ STATUS_CHANGED = "status_changed"
32
+ LINK_ADDED = "link_added"
33
+ COMMENT_ADDED = "comment_added"
34
+
35
+
36
+ @dataclass
37
+ class BroadcastEvent:
38
+ """
39
+ Event for cross-session broadcasting.
40
+
41
+ Contains all information needed to synchronize changes across
42
+ multiple active sessions.
43
+ """
44
+
45
+ event_type: BroadcastEventType
46
+ resource_id: str # feature_id, track_id, etc.
47
+ resource_type: str # "feature", "track", "spike"
48
+ agent_id: str
49
+ session_id: str
50
+ payload: dict[str, Any]
51
+ timestamp: datetime = field(default_factory=datetime.now)
52
+
53
+ def to_dict(self) -> dict[str, Any]:
54
+ """Convert to dictionary for JSON serialization."""
55
+ return {
56
+ "type": "broadcast_event",
57
+ "event_type": self.event_type.value,
58
+ "resource_id": self.resource_id,
59
+ "resource_type": self.resource_type,
60
+ "agent_id": self.agent_id,
61
+ "session_id": self.session_id,
62
+ "payload": self.payload,
63
+ "timestamp": self.timestamp.isoformat(),
64
+ }
65
+
66
+
67
+ class CrossSessionBroadcaster:
68
+ """
69
+ Broadcasts feature/track/spike updates across all active sessions.
70
+
71
+ Enables real-time coordination between multiple agents working
72
+ on the same project simultaneously.
73
+
74
+ Example:
75
+ >>> broadcaster = CrossSessionBroadcaster(websocket_manager, db_path)
76
+ >>> await broadcaster.broadcast_feature_update(
77
+ ... feature_id="feat-123",
78
+ ... agent_id="claude-1",
79
+ ... session_id="sess-1",
80
+ ... payload={"status": "in_progress"}
81
+ ... )
82
+ >>> # All other sessions immediately see the update
83
+ """
84
+
85
+ def __init__(self, websocket_manager: Any, db_path: str):
86
+ """
87
+ Initialize broadcaster.
88
+
89
+ Args:
90
+ websocket_manager: WebSocketManager instance for distribution
91
+ db_path: Path to SQLite database for audit trail
92
+ """
93
+ self.websocket_manager = websocket_manager
94
+ self.db_path = db_path
95
+ self.event_cache: dict[str, float] = {} # Simple deduplication cache
96
+
97
+ async def broadcast_feature_update(
98
+ self,
99
+ feature_id: str,
100
+ agent_id: str,
101
+ session_id: str,
102
+ payload: dict[str, Any],
103
+ ) -> int:
104
+ """
105
+ Broadcast feature update to all sessions.
106
+
107
+ Args:
108
+ feature_id: Feature being updated
109
+ agent_id: Agent making the change
110
+ session_id: Source session
111
+ payload: Update details (title, status, description, etc.)
112
+
113
+ Returns:
114
+ Number of clients that received the broadcast
115
+ """
116
+ event = BroadcastEvent(
117
+ event_type=BroadcastEventType.FEATURE_UPDATED,
118
+ resource_id=feature_id,
119
+ resource_type="feature",
120
+ agent_id=agent_id,
121
+ session_id=session_id,
122
+ payload=payload,
123
+ )
124
+
125
+ # Broadcast to all sessions
126
+ clients_notified = await self.websocket_manager.broadcast_to_all_sessions(
127
+ event.to_dict()
128
+ )
129
+
130
+ logger.info(
131
+ f"Broadcast feature {feature_id} update to {clients_notified} clients "
132
+ f"(agent={agent_id}, session={session_id})"
133
+ )
134
+
135
+ return int(clients_notified)
136
+
137
+ async def broadcast_feature_created(
138
+ self,
139
+ feature_id: str,
140
+ agent_id: str,
141
+ session_id: str,
142
+ payload: dict[str, Any],
143
+ ) -> int:
144
+ """
145
+ Broadcast new feature creation to all sessions.
146
+
147
+ Args:
148
+ feature_id: New feature ID
149
+ agent_id: Agent creating the feature
150
+ session_id: Source session
151
+ payload: Feature details
152
+
153
+ Returns:
154
+ Number of clients notified
155
+ """
156
+ event = BroadcastEvent(
157
+ event_type=BroadcastEventType.FEATURE_CREATED,
158
+ resource_id=feature_id,
159
+ resource_type="feature",
160
+ agent_id=agent_id,
161
+ session_id=session_id,
162
+ payload=payload,
163
+ )
164
+
165
+ clients_notified = await self.websocket_manager.broadcast_to_all_sessions(
166
+ event.to_dict()
167
+ )
168
+
169
+ logger.info(
170
+ f"Broadcast new feature {feature_id} to {clients_notified} clients "
171
+ f"(agent={agent_id})"
172
+ )
173
+
174
+ return int(clients_notified)
175
+
176
+ async def broadcast_status_change(
177
+ self,
178
+ feature_id: str,
179
+ old_status: str,
180
+ new_status: str,
181
+ agent_id: str,
182
+ session_id: str,
183
+ ) -> int:
184
+ """
185
+ Broadcast feature status change across sessions.
186
+
187
+ Args:
188
+ feature_id: Feature being updated
189
+ old_status: Previous status
190
+ new_status: New status
191
+ agent_id: Agent making change
192
+ session_id: Source session
193
+
194
+ Returns:
195
+ Number of clients notified
196
+ """
197
+ return await self.broadcast_feature_update(
198
+ feature_id=feature_id,
199
+ agent_id=agent_id,
200
+ session_id=session_id,
201
+ payload={
202
+ "change_type": "status",
203
+ "old_status": old_status,
204
+ "new_status": new_status,
205
+ },
206
+ )
207
+
208
+ async def broadcast_link_added(
209
+ self,
210
+ feature_id: str,
211
+ linked_feature_id: str,
212
+ link_type: str, # "depends_on", "blocks", "related", etc.
213
+ agent_id: str,
214
+ session_id: str,
215
+ ) -> int:
216
+ """
217
+ Broadcast when feature links are added.
218
+
219
+ Args:
220
+ feature_id: Source feature
221
+ linked_feature_id: Target feature
222
+ link_type: Type of relationship
223
+ agent_id: Agent making change
224
+ session_id: Source session
225
+
226
+ Returns:
227
+ Number of clients notified
228
+ """
229
+ return await self.broadcast_feature_update(
230
+ feature_id=feature_id,
231
+ agent_id=agent_id,
232
+ session_id=session_id,
233
+ payload={
234
+ "change_type": "link_added",
235
+ "linked_feature_id": linked_feature_id,
236
+ "link_type": link_type,
237
+ },
238
+ )
239
+
240
+ async def broadcast_track_update(
241
+ self,
242
+ track_id: str,
243
+ agent_id: str,
244
+ session_id: str,
245
+ payload: dict[str, Any],
246
+ ) -> int:
247
+ """
248
+ Broadcast track update to all sessions.
249
+
250
+ Args:
251
+ track_id: Track being updated
252
+ agent_id: Agent making change
253
+ session_id: Source session
254
+ payload: Update details
255
+
256
+ Returns:
257
+ Number of clients notified
258
+ """
259
+ event = BroadcastEvent(
260
+ event_type=BroadcastEventType.TRACK_UPDATED,
261
+ resource_id=track_id,
262
+ resource_type="track",
263
+ agent_id=agent_id,
264
+ session_id=session_id,
265
+ payload=payload,
266
+ )
267
+
268
+ clients_notified = await self.websocket_manager.broadcast_to_all_sessions(
269
+ event.to_dict()
270
+ )
271
+
272
+ logger.info(
273
+ f"Broadcast track {track_id} update to {clients_notified} clients "
274
+ f"(agent={agent_id})"
275
+ )
276
+
277
+ return int(clients_notified)
278
+
279
+ async def broadcast_spike_update(
280
+ self,
281
+ spike_id: str,
282
+ agent_id: str,
283
+ session_id: str,
284
+ payload: dict[str, Any],
285
+ ) -> int:
286
+ """
287
+ Broadcast spike update to all sessions.
288
+
289
+ Args:
290
+ spike_id: Spike being updated
291
+ agent_id: Agent making change
292
+ session_id: Source session
293
+ payload: Update details
294
+
295
+ Returns:
296
+ Number of clients notified
297
+ """
298
+ event = BroadcastEvent(
299
+ event_type=BroadcastEventType.SPIKE_UPDATED,
300
+ resource_id=spike_id,
301
+ resource_type="spike",
302
+ agent_id=agent_id,
303
+ session_id=session_id,
304
+ payload=payload,
305
+ )
306
+
307
+ clients_notified = await self.websocket_manager.broadcast_to_all_sessions(
308
+ event.to_dict()
309
+ )
310
+
311
+ logger.info(
312
+ f"Broadcast spike {spike_id} update to {clients_notified} clients "
313
+ f"(agent={agent_id})"
314
+ )
315
+
316
+ return int(clients_notified)
@@ -0,0 +1,357 @@
1
+ """
2
+ Broadcast API Routes - Cross-Session Synchronization
3
+
4
+ REST API endpoints for broadcasting feature/track/spike updates
5
+ across all active sessions.
6
+ """
7
+
8
+ import logging
9
+ from typing import Any
10
+
11
+ from fastapi import APIRouter, Header, HTTPException
12
+ from pydantic import BaseModel
13
+
14
+ from htmlgraph.api.broadcast import CrossSessionBroadcaster
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class FeatureUpdateRequest(BaseModel):
20
+ """Request model for feature update broadcasts."""
21
+
22
+ title: str | None = None
23
+ status: str | None = None
24
+ description: str | None = None
25
+ tags: list[str] | None = None
26
+ priority: str | None = None
27
+
28
+
29
+ class StatusChangeRequest(BaseModel):
30
+ """Request model for status change broadcasts."""
31
+
32
+ new_status: str
33
+
34
+
35
+ class LinkAddRequest(BaseModel):
36
+ """Request model for link addition broadcasts."""
37
+
38
+ linked_feature_id: str
39
+ link_type: str
40
+
41
+
42
+ def create_broadcast_router(
43
+ broadcaster: CrossSessionBroadcaster,
44
+ get_db: Any,
45
+ ) -> APIRouter:
46
+ """
47
+ Create broadcast API router with dependency injection.
48
+
49
+ Args:
50
+ broadcaster: CrossSessionBroadcaster instance
51
+ get_db: Database connection factory
52
+
53
+ Returns:
54
+ Configured APIRouter
55
+ """
56
+ router = APIRouter(prefix="/api/broadcast", tags=["broadcast"])
57
+
58
+ @router.post("/features/{feature_id}")
59
+ async def broadcast_feature_update(
60
+ feature_id: str,
61
+ update: FeatureUpdateRequest,
62
+ agent_id: str = Header(..., alias="X-Agent-ID"),
63
+ session_id: str = Header(..., alias="X-Session-ID"),
64
+ ) -> dict[str, Any]:
65
+ """
66
+ Broadcast feature update to all sessions.
67
+
68
+ Headers:
69
+ - X-Agent-ID: Agent making the change
70
+ - X-Session-ID: Source session ID
71
+
72
+ Body:
73
+ ```json
74
+ {
75
+ "title": "new title",
76
+ "status": "in_progress",
77
+ "description": "updated description",
78
+ "tags": ["api", "backend"],
79
+ "priority": "high"
80
+ }
81
+ ```
82
+
83
+ Returns:
84
+ ```json
85
+ {
86
+ "success": true,
87
+ "feature_id": "feat-123",
88
+ "clients_notified": 5,
89
+ "timestamp": "2025-01-01T12:00:00"
90
+ }
91
+ ```
92
+ """
93
+ # Validate feature exists
94
+ try:
95
+ db = await get_db()
96
+ try:
97
+ cursor = await db.execute(
98
+ "SELECT id FROM nodes WHERE id = ? AND type = 'feature'",
99
+ [feature_id],
100
+ )
101
+ if not await cursor.fetchone():
102
+ raise HTTPException(
103
+ status_code=404, detail=f"Feature {feature_id} not found"
104
+ )
105
+ finally:
106
+ await db.close()
107
+ except HTTPException:
108
+ raise
109
+ except Exception as e:
110
+ logger.error(f"Database error checking feature: {e}")
111
+ raise HTTPException(status_code=500, detail=str(e))
112
+
113
+ # Build payload from request
114
+ payload = {}
115
+ if update.title is not None:
116
+ payload["title"] = update.title
117
+ if update.status is not None:
118
+ payload["status"] = update.status
119
+ if update.description is not None:
120
+ payload["description"] = update.description
121
+ if update.tags is not None:
122
+ payload["tags"] = ",".join(update.tags) if update.tags else ""
123
+ if update.priority is not None:
124
+ payload["priority"] = update.priority
125
+
126
+ # Broadcast update
127
+ clients_notified = await broadcaster.broadcast_feature_update(
128
+ feature_id=feature_id,
129
+ agent_id=agent_id,
130
+ session_id=session_id,
131
+ payload=payload,
132
+ )
133
+
134
+ from datetime import datetime
135
+
136
+ return {
137
+ "success": True,
138
+ "feature_id": feature_id,
139
+ "clients_notified": clients_notified,
140
+ "timestamp": datetime.now().isoformat(),
141
+ }
142
+
143
+ @router.post("/features/{feature_id}/status")
144
+ async def update_feature_status(
145
+ feature_id: str,
146
+ request: StatusChangeRequest,
147
+ agent_id: str = Header(..., alias="X-Agent-ID"),
148
+ session_id: str = Header(..., alias="X-Session-ID"),
149
+ ) -> dict[str, Any]:
150
+ """
151
+ Update feature status and broadcast to all sessions.
152
+
153
+ Valid statuses: todo, in_progress, blocked, done
154
+ """
155
+ # Validate status
156
+ valid_statuses = ["todo", "in_progress", "blocked", "done"]
157
+ if request.new_status not in valid_statuses:
158
+ raise HTTPException(
159
+ status_code=400,
160
+ detail=f"Invalid status: {request.new_status}. Must be one of {valid_statuses}",
161
+ )
162
+
163
+ # Get current status
164
+ try:
165
+ db = await get_db()
166
+ try:
167
+ cursor = await db.execute(
168
+ "SELECT status FROM nodes WHERE id = ? AND type = 'feature'",
169
+ [feature_id],
170
+ )
171
+ row = await cursor.fetchone()
172
+ if not row:
173
+ raise HTTPException(
174
+ status_code=404, detail=f"Feature {feature_id} not found"
175
+ )
176
+
177
+ old_status = row[0] if row[0] else "todo"
178
+ finally:
179
+ await db.close()
180
+ except HTTPException:
181
+ raise
182
+ except Exception as e:
183
+ logger.error(f"Database error fetching feature status: {e}")
184
+ raise HTTPException(status_code=500, detail=str(e))
185
+
186
+ # Broadcast status change
187
+ clients_notified = await broadcaster.broadcast_status_change(
188
+ feature_id=feature_id,
189
+ old_status=old_status,
190
+ new_status=request.new_status,
191
+ agent_id=agent_id,
192
+ session_id=session_id,
193
+ )
194
+
195
+ from datetime import datetime
196
+
197
+ return {
198
+ "success": True,
199
+ "feature_id": feature_id,
200
+ "old_status": old_status,
201
+ "new_status": request.new_status,
202
+ "clients_notified": clients_notified,
203
+ "timestamp": datetime.now().isoformat(),
204
+ }
205
+
206
+ @router.post("/features/{feature_id}/links")
207
+ async def add_feature_link(
208
+ feature_id: str,
209
+ request: LinkAddRequest,
210
+ agent_id: str = Header(..., alias="X-Agent-ID"),
211
+ session_id: str = Header(..., alias="X-Session-ID"),
212
+ ) -> dict[str, Any]:
213
+ """
214
+ Add feature link and broadcast to all sessions.
215
+
216
+ Valid link types: depends_on, blocks, related
217
+ """
218
+ # Validate link type
219
+ valid_link_types = ["depends_on", "blocks", "related"]
220
+ if request.link_type not in valid_link_types:
221
+ raise HTTPException(
222
+ status_code=400,
223
+ detail=f"Invalid link type: {request.link_type}. Must be one of {valid_link_types}",
224
+ )
225
+
226
+ # Validate both features exist
227
+ try:
228
+ db = await get_db()
229
+ try:
230
+ cursor = await db.execute(
231
+ "SELECT id FROM nodes WHERE id IN (?, ?) AND type = 'feature'",
232
+ [feature_id, request.linked_feature_id],
233
+ )
234
+ rows = await cursor.fetchall()
235
+ if len(rows) != 2:
236
+ raise HTTPException(
237
+ status_code=404, detail="One or both features not found"
238
+ )
239
+ finally:
240
+ await db.close()
241
+ except HTTPException:
242
+ raise
243
+ except Exception as e:
244
+ logger.error(f"Database error validating features: {e}")
245
+ raise HTTPException(status_code=500, detail=str(e))
246
+
247
+ # Broadcast link addition
248
+ clients_notified = await broadcaster.broadcast_link_added(
249
+ feature_id=feature_id,
250
+ linked_feature_id=request.linked_feature_id,
251
+ link_type=request.link_type,
252
+ agent_id=agent_id,
253
+ session_id=session_id,
254
+ )
255
+
256
+ from datetime import datetime
257
+
258
+ return {
259
+ "success": True,
260
+ "feature_id": feature_id,
261
+ "linked_feature_id": request.linked_feature_id,
262
+ "link_type": request.link_type,
263
+ "clients_notified": clients_notified,
264
+ "timestamp": datetime.now().isoformat(),
265
+ }
266
+
267
+ @router.post("/tracks/{track_id}")
268
+ async def broadcast_track_update(
269
+ track_id: str,
270
+ update: dict[str, Any],
271
+ agent_id: str = Header(..., alias="X-Agent-ID"),
272
+ session_id: str = Header(..., alias="X-Session-ID"),
273
+ ) -> dict[str, Any]:
274
+ """Broadcast track update to all sessions."""
275
+ # Validate track exists
276
+ try:
277
+ db = await get_db()
278
+ try:
279
+ cursor = await db.execute(
280
+ "SELECT id FROM nodes WHERE id = ? AND type = 'track'",
281
+ [track_id],
282
+ )
283
+ if not await cursor.fetchone():
284
+ raise HTTPException(
285
+ status_code=404, detail=f"Track {track_id} not found"
286
+ )
287
+ finally:
288
+ await db.close()
289
+ except HTTPException:
290
+ raise
291
+ except Exception as e:
292
+ logger.error(f"Database error checking track: {e}")
293
+ raise HTTPException(status_code=500, detail=str(e))
294
+
295
+ # Broadcast update
296
+ clients_notified = await broadcaster.broadcast_track_update(
297
+ track_id=track_id,
298
+ agent_id=agent_id,
299
+ session_id=session_id,
300
+ payload=update,
301
+ )
302
+
303
+ from datetime import datetime
304
+
305
+ return {
306
+ "success": True,
307
+ "track_id": track_id,
308
+ "clients_notified": clients_notified,
309
+ "timestamp": datetime.now().isoformat(),
310
+ }
311
+
312
+ @router.post("/spikes/{spike_id}")
313
+ async def broadcast_spike_update(
314
+ spike_id: str,
315
+ update: dict[str, Any],
316
+ agent_id: str = Header(..., alias="X-Agent-ID"),
317
+ session_id: str = Header(..., alias="X-Session-ID"),
318
+ ) -> dict[str, Any]:
319
+ """Broadcast spike update to all sessions."""
320
+ # Validate spike exists
321
+ try:
322
+ db = await get_db()
323
+ try:
324
+ cursor = await db.execute(
325
+ "SELECT id FROM nodes WHERE id = ? AND type = 'spike'",
326
+ [spike_id],
327
+ )
328
+ if not await cursor.fetchone():
329
+ raise HTTPException(
330
+ status_code=404, detail=f"Spike {spike_id} not found"
331
+ )
332
+ finally:
333
+ await db.close()
334
+ except HTTPException:
335
+ raise
336
+ except Exception as e:
337
+ logger.error(f"Database error checking spike: {e}")
338
+ raise HTTPException(status_code=500, detail=str(e))
339
+
340
+ # Broadcast update
341
+ clients_notified = await broadcaster.broadcast_spike_update(
342
+ spike_id=spike_id,
343
+ agent_id=agent_id,
344
+ session_id=session_id,
345
+ payload=update,
346
+ )
347
+
348
+ from datetime import datetime
349
+
350
+ return {
351
+ "success": True,
352
+ "spike_id": spike_id,
353
+ "clients_notified": clients_notified,
354
+ "timestamp": datetime.now().isoformat(),
355
+ }
356
+
357
+ return router