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.
- 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 +135 -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/static/presence-widget-demo.html +785 -0
- htmlgraph/api/sync_routes.py +184 -0
- htmlgraph/api/templates/partials/agents.html +308 -80
- htmlgraph/api/websocket.py +112 -37
- htmlgraph/broadcast_integration.py +227 -0
- htmlgraph/cli_commands/sync.py +207 -0
- htmlgraph/db/schema.py +226 -0
- htmlgraph/hooks/event_tracker.py +53 -2
- htmlgraph/models.py +1 -0
- htmlgraph/reactive_integration.py +148 -0
- htmlgraph/session_manager.py +7 -0
- htmlgraph/sync/__init__.py +21 -0
- htmlgraph/sync/git_sync.py +458 -0
- {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.1.dist-info}/METADATA +1 -1
- {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.1.dist-info}/RECORD +32 -19
- htmlgraph/dashboard.html +0 -6592
- htmlgraph-0.27.7.data/data/htmlgraph/dashboard.html +0 -6592
- {htmlgraph-0.27.7.data → htmlgraph-0.28.1.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.27.7.data → htmlgraph-0.28.1.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.27.7.data → htmlgraph-0.28.1.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.27.7.data → htmlgraph-0.28.1.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.1.dist-info}/WHEEL +0 -0
- {htmlgraph-0.27.7.dist-info → htmlgraph-0.28.1.dist-info}/entry_points.txt +0 -0
htmlgraph/api/offline.py
ADDED
|
@@ -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
|
+
)
|