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
@@ -0,0 +1,776 @@
1
+ """
2
+ Offline-First Merge with Conflict Resolution - Phase 4A
3
+
4
+ Supports offline work on multiple devices with automatic conflict detection and resolution.
5
+ Agents can work offline, cache updates locally, and automatically merge changes on reconnect.
6
+
7
+ Features:
8
+ - Offline event logging
9
+ - Last-write-wins merge strategy
10
+ - Priority-based conflict resolution
11
+ - Conflict tracking and audit trail
12
+ - <1s merge time for 100 events
13
+ - Zero data loss
14
+
15
+ Architecture:
16
+ - OfflineEventLog: Tracks changes made while offline
17
+ - EventMerger: Merges local and remote events with configurable strategies
18
+ - ConflictTracker: Logs and manages merge conflicts
19
+ - ReconnectionManager: Handles reconnection and synchronization
20
+ """
21
+
22
+ import json
23
+ import logging
24
+ import uuid
25
+ from dataclasses import dataclass
26
+ from datetime import datetime
27
+ from enum import Enum
28
+ from typing import Any
29
+
30
+ import aiosqlite
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ class OfflineEventStatus(str, Enum):
36
+ """Status of an offline event."""
37
+
38
+ LOCAL_ONLY = "local_only" # Created offline, not yet synced
39
+ SYNCED = "synced" # Successfully synced to server
40
+ CONFLICT = "conflict" # Detected conflict during merge
41
+ RESOLVED = "resolved" # Conflict manually resolved
42
+
43
+
44
+ class MergeStrategy(str, Enum):
45
+ """Strategy for resolving conflicts during merge."""
46
+
47
+ LAST_WRITE_WINS = "last_write_wins" # Most recent timestamp wins
48
+ PRIORITY_BASED = "priority_based" # Higher priority resource wins
49
+ USER_CHOICE = "user_choice" # Manual user resolution required
50
+
51
+
52
+ @dataclass
53
+ class OfflineEvent:
54
+ """Event created while offline."""
55
+
56
+ event_id: str
57
+ agent_id: str
58
+ resource_id: str # feature_id, track_id, etc.
59
+ resource_type: str # feature, track, spike, etc.
60
+ operation: str # create, update, delete
61
+ timestamp: datetime
62
+ payload: dict[str, Any]
63
+ status: OfflineEventStatus = OfflineEventStatus.LOCAL_ONLY
64
+
65
+ def to_dict(self) -> dict[str, Any]:
66
+ """Convert to dictionary for serialization."""
67
+ return {
68
+ "event_id": self.event_id,
69
+ "agent_id": self.agent_id,
70
+ "resource_id": self.resource_id,
71
+ "resource_type": self.resource_type,
72
+ "operation": self.operation,
73
+ "timestamp": self.timestamp.isoformat(),
74
+ "payload": self.payload,
75
+ "status": self.status.value,
76
+ }
77
+
78
+
79
+ @dataclass
80
+ class ConflictInfo:
81
+ """Information about a detected conflict."""
82
+
83
+ local_event: OfflineEvent
84
+ remote_event: dict[str, Any]
85
+ conflict_type: str # "concurrent_modification", "delete_update", etc.
86
+ local_timestamp: datetime
87
+ remote_timestamp: datetime
88
+ resolution_strategy: MergeStrategy
89
+ winner: str = "" # "local" or "remote" after resolution
90
+
91
+ def to_dict(self) -> dict[str, Any]:
92
+ """Convert to dictionary for serialization."""
93
+ return {
94
+ "local_event_id": self.local_event.event_id,
95
+ "remote_event_id": self.remote_event.get("event_id", ""),
96
+ "resource_id": self.local_event.resource_id,
97
+ "conflict_type": self.conflict_type,
98
+ "local_timestamp": self.local_timestamp.isoformat(),
99
+ "remote_timestamp": self.remote_timestamp.isoformat(),
100
+ "resolution_strategy": self.resolution_strategy.value,
101
+ "winner": self.winner,
102
+ }
103
+
104
+
105
+ class OfflineEventLog:
106
+ """
107
+ Tracks changes made while offline.
108
+
109
+ Stores events locally in SQLite and manages their synchronization status.
110
+ """
111
+
112
+ def __init__(self, db_path: str):
113
+ """
114
+ Initialize offline event log.
115
+
116
+ Args:
117
+ db_path: Path to SQLite database
118
+ """
119
+ self.db_path = db_path
120
+ self.local_events: list[OfflineEvent] = []
121
+
122
+ async def log_event(self, event: OfflineEvent) -> bool:
123
+ """
124
+ Log an offline event.
125
+
126
+ Args:
127
+ event: Event to log
128
+
129
+ Returns:
130
+ True if successful, False otherwise
131
+ """
132
+ self.local_events.append(event)
133
+ return await self._persist_event(event)
134
+
135
+ async def _persist_event(self, event: OfflineEvent) -> bool:
136
+ """
137
+ Persist event to offline_events table.
138
+
139
+ Args:
140
+ event: Event to persist
141
+
142
+ Returns:
143
+ True if successful, False otherwise
144
+ """
145
+ try:
146
+ async with aiosqlite.connect(self.db_path) as db:
147
+ await db.execute(
148
+ """
149
+ INSERT INTO offline_events
150
+ (event_id, agent_id, resource_id, resource_type,
151
+ operation, timestamp, payload, status)
152
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
153
+ """,
154
+ [
155
+ event.event_id,
156
+ event.agent_id,
157
+ event.resource_id,
158
+ event.resource_type,
159
+ event.operation,
160
+ event.timestamp.isoformat(),
161
+ json.dumps(event.payload),
162
+ event.status.value,
163
+ ],
164
+ )
165
+ await db.commit()
166
+ return True
167
+ except Exception as e:
168
+ logger.error(f"Error persisting offline event: {e}")
169
+ return False
170
+
171
+ async def get_unsynced_events(self) -> list[OfflineEvent]:
172
+ """
173
+ Get all events that haven't been synced to server.
174
+
175
+ Returns:
176
+ List of unsynced offline events
177
+ """
178
+ try:
179
+ async with aiosqlite.connect(self.db_path) as db:
180
+ cursor = await db.execute(
181
+ """
182
+ SELECT event_id, agent_id, resource_id, resource_type,
183
+ operation, timestamp, payload, status
184
+ FROM offline_events
185
+ WHERE status = ?
186
+ ORDER BY timestamp ASC
187
+ """,
188
+ [OfflineEventStatus.LOCAL_ONLY.value],
189
+ )
190
+
191
+ rows = await cursor.fetchall()
192
+ events = []
193
+ for row in rows:
194
+ event = OfflineEvent(
195
+ event_id=row[0],
196
+ agent_id=row[1],
197
+ resource_id=row[2],
198
+ resource_type=row[3],
199
+ operation=row[4],
200
+ timestamp=datetime.fromisoformat(row[5]),
201
+ payload=json.loads(row[6]) if row[6] else {},
202
+ status=OfflineEventStatus(row[7]),
203
+ )
204
+ events.append(event)
205
+
206
+ return events
207
+ except Exception as e:
208
+ logger.error(f"Error fetching unsynced events: {e}")
209
+ return []
210
+
211
+ async def mark_synced(self, event_id: str) -> bool:
212
+ """
213
+ Mark event as synced.
214
+
215
+ Args:
216
+ event_id: Event ID to mark as synced
217
+
218
+ Returns:
219
+ True if successful, False otherwise
220
+ """
221
+ try:
222
+ # Update in-memory cache
223
+ for event in self.local_events:
224
+ if event.event_id == event_id:
225
+ event.status = OfflineEventStatus.SYNCED
226
+ break
227
+
228
+ # Update database
229
+ async with aiosqlite.connect(self.db_path) as db:
230
+ await db.execute(
231
+ """
232
+ UPDATE offline_events SET status = ?
233
+ WHERE event_id = ?
234
+ """,
235
+ [OfflineEventStatus.SYNCED.value, event_id],
236
+ )
237
+ await db.commit()
238
+ return True
239
+ except Exception as e:
240
+ logger.error(f"Error marking event as synced: {e}")
241
+ return False
242
+
243
+ async def mark_conflict(self, event_id: str) -> bool:
244
+ """
245
+ Mark event as having a conflict.
246
+
247
+ Args:
248
+ event_id: Event ID to mark as conflicted
249
+
250
+ Returns:
251
+ True if successful, False otherwise
252
+ """
253
+ try:
254
+ # Update in-memory cache
255
+ for event in self.local_events:
256
+ if event.event_id == event_id:
257
+ event.status = OfflineEventStatus.CONFLICT
258
+ break
259
+
260
+ # Update database
261
+ async with aiosqlite.connect(self.db_path) as db:
262
+ await db.execute(
263
+ """
264
+ UPDATE offline_events SET status = ?
265
+ WHERE event_id = ?
266
+ """,
267
+ [OfflineEventStatus.CONFLICT.value, event_id],
268
+ )
269
+ await db.commit()
270
+ return True
271
+ except Exception as e:
272
+ logger.error(f"Error marking event as conflict: {e}")
273
+ return False
274
+
275
+
276
+ class EventMerger:
277
+ """
278
+ Merges offline events with remote events using configurable strategies.
279
+
280
+ Supports:
281
+ - Last-write-wins (timestamp-based)
282
+ - Priority-based (feature priority)
283
+ - User choice (manual resolution)
284
+ """
285
+
286
+ def __init__(
287
+ self, db_path: str, strategy: MergeStrategy = MergeStrategy.LAST_WRITE_WINS
288
+ ):
289
+ """
290
+ Initialize event merger.
291
+
292
+ Args:
293
+ db_path: Path to SQLite database
294
+ strategy: Conflict resolution strategy
295
+ """
296
+ self.db_path = db_path
297
+ self.strategy = strategy
298
+ self.conflicts: list[ConflictInfo] = []
299
+
300
+ async def merge_events(
301
+ self,
302
+ local_events: list[OfflineEvent],
303
+ remote_events: list[dict[str, Any]],
304
+ ) -> dict[str, Any]:
305
+ """
306
+ Merge local offline events with remote events.
307
+
308
+ Args:
309
+ local_events: Events created offline
310
+ remote_events: Events from server
311
+
312
+ Returns:
313
+ Dictionary with merge results:
314
+ {
315
+ "merged_events": [list of merged events],
316
+ "conflicts": [list of conflicts],
317
+ "resolution_strategy": "last_write_wins",
318
+ "conflict_count": 2
319
+ }
320
+ """
321
+ merged_events = []
322
+ conflicts = []
323
+
324
+ # Create mapping of remote events by resource
325
+ remote_by_resource: dict[tuple[str, str], dict[str, Any]] = {}
326
+ for remote_event in remote_events:
327
+ key = (remote_event["resource_id"], remote_event["operation"])
328
+ remote_by_resource[key] = remote_event
329
+
330
+ # Process each local event
331
+ for local_event in local_events:
332
+ key = (local_event.resource_id, local_event.operation)
333
+
334
+ if key in remote_by_resource:
335
+ # Conflict: both modified same resource
336
+ remote_event = remote_by_resource[key]
337
+
338
+ # Detect conflict
339
+ conflict = ConflictInfo(
340
+ local_event=local_event,
341
+ remote_event=remote_event,
342
+ conflict_type="concurrent_modification",
343
+ local_timestamp=local_event.timestamp,
344
+ remote_timestamp=datetime.fromisoformat(remote_event["timestamp"]),
345
+ resolution_strategy=self.strategy,
346
+ )
347
+
348
+ # Resolve based on strategy
349
+ resolved = await self._resolve_conflict(conflict)
350
+
351
+ if resolved:
352
+ merged_events.append(resolved)
353
+ conflicts.append(conflict)
354
+ else:
355
+ # No conflict: use local event
356
+ merged_events.append(local_event)
357
+
358
+ # Add remote events that have no local counterpart
359
+ local_keys = {(e.resource_id, e.operation) for e in local_events}
360
+ for remote_event in remote_events:
361
+ key = (remote_event["resource_id"], remote_event["operation"])
362
+ if key not in local_keys:
363
+ merged_events.append(remote_event)
364
+
365
+ return {
366
+ "merged_events": merged_events,
367
+ "conflicts": conflicts,
368
+ "resolution_strategy": self.strategy.value,
369
+ "conflict_count": len(conflicts),
370
+ }
371
+
372
+ async def _resolve_conflict(
373
+ self, conflict: ConflictInfo
374
+ ) -> OfflineEvent | dict[str, Any]:
375
+ """
376
+ Resolve a conflict using configured strategy.
377
+
378
+ Args:
379
+ conflict: Conflict information
380
+
381
+ Returns:
382
+ Winning event (local or remote)
383
+ """
384
+ if self.strategy == MergeStrategy.LAST_WRITE_WINS:
385
+ return self._resolve_last_write_wins(conflict)
386
+ elif self.strategy == MergeStrategy.PRIORITY_BASED:
387
+ return await self._resolve_priority_based(conflict)
388
+ else:
389
+ # USER_CHOICE: return conflict for manual review
390
+ return conflict.local_event
391
+
392
+ def _resolve_last_write_wins(
393
+ self, conflict: ConflictInfo
394
+ ) -> OfflineEvent | dict[str, Any]:
395
+ """
396
+ Simple last-write-wins: whoever has later timestamp wins.
397
+
398
+ Args:
399
+ conflict: Conflict information
400
+
401
+ Returns:
402
+ Winning event
403
+ """
404
+ if conflict.local_timestamp > conflict.remote_timestamp:
405
+ conflict.winner = "local"
406
+ return conflict.local_event
407
+ else:
408
+ conflict.winner = "remote"
409
+ return conflict.remote_event
410
+
411
+ async def _resolve_priority_based(
412
+ self, conflict: ConflictInfo
413
+ ) -> OfflineEvent | dict[str, Any]:
414
+ """
415
+ Priority-based: use feature/resource priority.
416
+
417
+ Args:
418
+ conflict: Conflict information
419
+
420
+ Returns:
421
+ Winning event based on priority
422
+ """
423
+ try:
424
+ local_priority = await self._get_resource_priority(
425
+ conflict.local_event.resource_id
426
+ )
427
+ remote_priority = await self._get_resource_priority(
428
+ conflict.remote_event["resource_id"]
429
+ )
430
+
431
+ if local_priority >= remote_priority:
432
+ conflict.winner = "local"
433
+ return conflict.local_event
434
+ else:
435
+ conflict.winner = "remote"
436
+ return conflict.remote_event
437
+ except Exception as e:
438
+ logger.error(f"Error resolving priority-based conflict: {e}")
439
+ # Fallback to last-write-wins
440
+ return self._resolve_last_write_wins(conflict)
441
+
442
+ async def _get_resource_priority(self, resource_id: str) -> int:
443
+ """
444
+ Get priority of a resource (higher = more important).
445
+
446
+ Args:
447
+ resource_id: Resource ID to check
448
+
449
+ Returns:
450
+ Priority value (0-3: low, medium, high, critical)
451
+ """
452
+ try:
453
+ async with aiosqlite.connect(self.db_path) as db:
454
+ cursor = await db.execute(
455
+ """
456
+ SELECT priority FROM features WHERE id = ?
457
+ """,
458
+ [resource_id],
459
+ )
460
+ row = await cursor.fetchone()
461
+
462
+ if row:
463
+ priority_map = {"low": 0, "medium": 1, "high": 2, "critical": 3}
464
+ return priority_map.get(row[0], 1)
465
+ return 1 # Default: medium priority
466
+ except Exception as e:
467
+ logger.error(f"Error fetching resource priority: {e}")
468
+ return 1
469
+
470
+
471
+ class ConflictTracker:
472
+ """
473
+ Tracks and manages merge conflicts.
474
+
475
+ Provides audit trail and resolution management for conflicts.
476
+ """
477
+
478
+ def __init__(self, db_path: str):
479
+ """
480
+ Initialize conflict tracker.
481
+
482
+ Args:
483
+ db_path: Path to SQLite database
484
+ """
485
+ self.db_path = db_path
486
+ self.conflicts: list[ConflictInfo] = []
487
+
488
+ async def log_conflict(self, conflict: ConflictInfo) -> bool:
489
+ """
490
+ Log a detected conflict for review.
491
+
492
+ Args:
493
+ conflict: Conflict information
494
+
495
+ Returns:
496
+ True if successful, False otherwise
497
+ """
498
+ self.conflicts.append(conflict)
499
+
500
+ try:
501
+ async with aiosqlite.connect(self.db_path) as db:
502
+ conflict_id = f"conf-{uuid.uuid4().hex[:8]}"
503
+ await db.execute(
504
+ """
505
+ INSERT INTO conflict_log
506
+ (conflict_id, local_event_id, remote_event_id, resource_id,
507
+ conflict_type, local_timestamp, remote_timestamp,
508
+ resolution_strategy, resolution, status)
509
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
510
+ """,
511
+ [
512
+ conflict_id,
513
+ conflict.local_event.event_id,
514
+ conflict.remote_event.get("event_id", ""),
515
+ conflict.local_event.resource_id,
516
+ conflict.conflict_type,
517
+ conflict.local_timestamp.isoformat(),
518
+ conflict.remote_timestamp.isoformat(),
519
+ conflict.resolution_strategy.value,
520
+ conflict.winner if conflict.winner else None,
521
+ "resolved" if conflict.winner else "pending_review",
522
+ ],
523
+ )
524
+ await db.commit()
525
+ return True
526
+ except Exception as e:
527
+ logger.error(f"Error logging conflict: {e}")
528
+ return False
529
+
530
+ async def get_pending_conflicts(self) -> list[ConflictInfo]:
531
+ """
532
+ Get all conflicts pending user review.
533
+
534
+ Returns:
535
+ List of unresolved conflicts
536
+ """
537
+ return [c for c in self.conflicts if c.winner == ""]
538
+
539
+ async def resolve_conflict(self, local_event_id: str, winner: str) -> bool:
540
+ """
541
+ User resolves a conflict by choosing winner.
542
+
543
+ Args:
544
+ local_event_id: Local event ID in conflict
545
+ winner: "local" or "remote"
546
+
547
+ Returns:
548
+ True if successful, False otherwise
549
+ """
550
+ try:
551
+ # Update in-memory cache
552
+ for conflict in self.conflicts:
553
+ if conflict.local_event.event_id == local_event_id:
554
+ conflict.winner = winner
555
+ break
556
+
557
+ # Update database
558
+ async with aiosqlite.connect(self.db_path) as db:
559
+ await db.execute(
560
+ """
561
+ UPDATE conflict_log
562
+ SET status = ?, resolution = ?
563
+ WHERE local_event_id = ?
564
+ """,
565
+ ["resolved", winner, local_event_id],
566
+ )
567
+ await db.commit()
568
+ return True
569
+ except Exception as e:
570
+ logger.error(f"Error resolving conflict: {e}")
571
+ return False
572
+
573
+ async def get_conflict_report(self) -> dict[str, Any]:
574
+ """
575
+ Generate report of all conflicts.
576
+
577
+ Returns:
578
+ Dictionary with conflict statistics and details
579
+ """
580
+ pending = await self.get_pending_conflicts()
581
+ resolved = [c for c in self.conflicts if c.winner != ""]
582
+
583
+ return {
584
+ "total_conflicts": len(self.conflicts),
585
+ "pending": len(pending),
586
+ "resolved": len(resolved),
587
+ "conflicts": [c.to_dict() for c in self.conflicts],
588
+ }
589
+
590
+
591
+ class ReconnectionManager:
592
+ """
593
+ Handles reconnection and sync with server.
594
+
595
+ Coordinates offline event log, merger, and conflict tracker to
596
+ automatically synchronize changes when connection is restored.
597
+ """
598
+
599
+ def __init__(
600
+ self,
601
+ offline_log: OfflineEventLog,
602
+ merger: EventMerger,
603
+ tracker: ConflictTracker,
604
+ ):
605
+ """
606
+ Initialize reconnection manager.
607
+
608
+ Args:
609
+ offline_log: Offline event log
610
+ merger: Event merger
611
+ tracker: Conflict tracker
612
+ """
613
+ self.offline_log = offline_log
614
+ self.merger = merger
615
+ self.tracker = tracker
616
+ self.is_online = False
617
+
618
+ async def on_reconnect(self) -> dict[str, Any]:
619
+ """
620
+ Called when connection is restored.
621
+
622
+ Syncs offline changes with server and resolves conflicts.
623
+
624
+ Returns:
625
+ Sync results dictionary with statistics
626
+ """
627
+ logger.info("Reconnecting: syncing offline changes...")
628
+
629
+ # Get unsynced events
630
+ unsynced = await self.offline_log.get_unsynced_events()
631
+ if not unsynced:
632
+ logger.info("No offline changes to sync")
633
+ return {
634
+ "synced_events": 0,
635
+ "conflicts": 0,
636
+ "status": "no_changes",
637
+ }
638
+
639
+ # Fetch remote events from server
640
+ remote_events = await self._fetch_remote_events()
641
+
642
+ # Merge events
643
+ merge_result = await self.merger.merge_events(unsynced, remote_events)
644
+
645
+ # Log conflicts for review
646
+ for conflict_info in merge_result.get("conflicts", []):
647
+ await self.tracker.log_conflict(conflict_info)
648
+
649
+ # Apply merged events to database
650
+ applied_count = 0
651
+ for event in merge_result["merged_events"]:
652
+ if isinstance(event, OfflineEvent):
653
+ await self.offline_log.mark_synced(event.event_id)
654
+ applied_count += 1
655
+ elif isinstance(event, dict):
656
+ applied_count += 1
657
+
658
+ # Apply to main database
659
+ await self._apply_event_to_db(event)
660
+
661
+ logger.info(
662
+ f"Sync complete: {applied_count} events synced, "
663
+ f"{len(merge_result['conflicts'])} conflicts"
664
+ )
665
+
666
+ # Notify dashboard of pending conflicts if any
667
+ if merge_result["conflicts"]:
668
+ await self._notify_conflicts(merge_result["conflicts"])
669
+
670
+ return {
671
+ "synced_events": applied_count,
672
+ "conflicts": len(merge_result["conflicts"]),
673
+ "status": "success",
674
+ "merge_strategy": merge_result["resolution_strategy"],
675
+ }
676
+
677
+ async def _fetch_remote_events(self) -> list[dict[str, Any]]:
678
+ """
679
+ Fetch remote events from server.
680
+
681
+ In a real implementation, this would call the server API.
682
+ For now, we return empty list (no remote changes).
683
+
684
+ Returns:
685
+ List of remote event dictionaries
686
+ """
687
+ # TODO: Implement actual server API call
688
+ # Example:
689
+ # response = await http_client.get(
690
+ # "https://server/api/events/recent?limit=1000"
691
+ # )
692
+ # return response.json()
693
+
694
+ logger.debug("Fetching remote events (not implemented yet)")
695
+ return []
696
+
697
+ async def _apply_event_to_db(self, event: OfflineEvent | dict[str, Any]) -> bool:
698
+ """
699
+ Apply event to main database.
700
+
701
+ Args:
702
+ event: Event to apply (OfflineEvent or dict)
703
+
704
+ Returns:
705
+ True if successful, False otherwise
706
+ """
707
+ try:
708
+ if isinstance(event, OfflineEvent):
709
+ resource_id = event.resource_id
710
+ resource_type = event.resource_type
711
+ operation = event.operation
712
+ payload = event.payload
713
+ else:
714
+ resource_id = str(event.get("resource_id", ""))
715
+ resource_type = str(event.get("resource_type", ""))
716
+ operation = str(event.get("operation", ""))
717
+ payload = event.get("payload", {})
718
+
719
+ # Apply to appropriate table based on resource_type
720
+ async with aiosqlite.connect(self.offline_log.db_path) as db:
721
+ if resource_type == "feature":
722
+ if operation == "update":
723
+ # Update feature
724
+ set_clauses = []
725
+ values = []
726
+ for key, value in payload.items():
727
+ set_clauses.append(f"{key} = ?")
728
+ values.append(value)
729
+
730
+ if set_clauses:
731
+ values.append(resource_id)
732
+ await db.execute(
733
+ f"""
734
+ UPDATE features
735
+ SET {", ".join(set_clauses)}, updated_at = CURRENT_TIMESTAMP
736
+ WHERE id = ?
737
+ """,
738
+ values,
739
+ )
740
+ elif operation == "create":
741
+ # Create feature (if not exists)
742
+ await db.execute(
743
+ """
744
+ INSERT OR IGNORE INTO features
745
+ (id, type, title, description, status, priority)
746
+ VALUES (?, ?, ?, ?, ?, ?)
747
+ """,
748
+ [
749
+ resource_id,
750
+ payload.get("type", "feature"),
751
+ payload.get("title", "Untitled"),
752
+ payload.get("description", ""),
753
+ payload.get("status", "todo"),
754
+ payload.get("priority", "medium"),
755
+ ],
756
+ )
757
+
758
+ await db.commit()
759
+ return True
760
+ except Exception as e:
761
+ logger.error(f"Error applying event to database: {e}")
762
+ return False
763
+
764
+ async def _notify_conflicts(self, conflicts: list[ConflictInfo]) -> None:
765
+ """
766
+ Notify dashboard of pending conflicts.
767
+
768
+ Args:
769
+ conflicts: List of conflicts to notify
770
+ """
771
+ # TODO: Implement WebSocket notification to dashboard
772
+ logger.info(f"Conflicts detected: {len(conflicts)} require review")
773
+ for conflict in conflicts:
774
+ logger.info(
775
+ f" - {conflict.conflict_type}: {conflict.local_event.resource_id}"
776
+ )