initial commit with charts and assistant chat

This commit is contained in:
2026-03-02 00:08:19 -04:00
commit d907c5765e
1828 changed files with 50054 additions and 0 deletions

View File

@@ -0,0 +1,174 @@
from collections import deque
from typing import Any, Dict, List, Optional, Tuple, Deque
import jsonpatch
from pydantic import BaseModel
from sync.protocol import SnapshotMessage, PatchMessage
class SyncEntry:
def __init__(self, model: BaseModel, store_name: str, history_size: int = 50):
self.model = model
self.store_name = store_name
self.seq = 0
self.last_snapshot = model.model_dump(mode="json")
self.history: Deque[Tuple[int, List[Dict[str, Any]]]] = deque(maxlen=history_size)
def compute_patch(self) -> Optional[List[Dict[str, Any]]]:
current_state = self.model.model_dump(mode="json")
patch = jsonpatch.make_patch(self.last_snapshot, current_state)
if not patch.patch:
return None
return patch.patch
def commit_patch(self, patch: List[Dict[str, Any]]):
self.seq += 1
self.history.append((self.seq, patch))
self.last_snapshot = self.model.model_dump(mode="json")
def catchup_patches(self, since_seq: int) -> Optional[List[Tuple[int, List[Dict[str, Any]]]]]:
if since_seq == self.seq:
return []
# Check if all patches from since_seq + 1 to self.seq are in history
if not self.history or self.history[0][0] > since_seq + 1:
return None
result = []
for seq, patch in self.history:
if seq > since_seq:
result.append((seq, patch))
return result
class SyncRegistry:
def __init__(self):
self.entries: Dict[str, SyncEntry] = {}
self.websocket: Optional[Any] = None # Expecting a FastAPI WebSocket or similar
def register(self, model: BaseModel, store_name: str):
self.entries[store_name] = SyncEntry(model, store_name)
async def push_all(self):
import logging
logger = logging.getLogger(__name__)
if not self.websocket:
logger.warning("push_all: No websocket connected, cannot push updates")
return
logger.info(f"push_all: Processing {len(self.entries)} store entries")
for entry in self.entries.values():
patch = entry.compute_patch()
if patch:
logger.info(f"push_all: Found patch for store '{entry.store_name}': {patch}")
entry.commit_patch(patch)
msg = PatchMessage(store=entry.store_name, seq=entry.seq, patch=patch)
logger.info(f"push_all: Sending patch message for '{entry.store_name}' seq={entry.seq}")
await self.websocket.send_json(msg.model_dump(mode="json"))
logger.info(f"push_all: Patch sent successfully for '{entry.store_name}'")
else:
logger.debug(f"push_all: No changes detected for store '{entry.store_name}'")
async def sync_client(self, client_seqs: Dict[str, int]):
if not self.websocket:
return
for store_name, entry in self.entries.items():
client_seq = client_seqs.get(store_name, -1)
patches = entry.catchup_patches(client_seq)
if patches is not None:
# Replay patches
for seq, patch in patches:
msg = PatchMessage(store=store_name, seq=seq, patch=patch)
await self.websocket.send_json(msg.model_dump(mode="json"))
else:
# Send full snapshot
msg = SnapshotMessage(
store=store_name,
seq=entry.seq,
state=entry.model.model_dump(mode="json")
)
await self.websocket.send_json(msg.model_dump(mode="json"))
async def apply_client_patch(self, store_name: str, client_base_seq: int, patch: List[Dict[str, Any]]):
import logging
logger = logging.getLogger(__name__)
logger.info(f"apply_client_patch: store={store_name}, client_base_seq={client_base_seq}, patch={patch}")
entry = self.entries.get(store_name)
if not entry:
logger.warning(f"apply_client_patch: Store '{store_name}' not found in registry")
return
logger.info(f"apply_client_patch: Current backend seq={entry.seq}")
if client_base_seq == entry.seq:
# No conflict
logger.info("apply_client_patch: No conflict - applying patch directly")
current_state = entry.model.model_dump(mode="json")
logger.info(f"apply_client_patch: Current state before patch: {current_state}")
new_state = jsonpatch.apply_patch(current_state, patch)
logger.info(f"apply_client_patch: New state after patch: {new_state}")
self._update_model(entry.model, new_state)
entry.commit_patch(patch)
logger.info(f"apply_client_patch: Patch committed, new seq={entry.seq}")
# Don't broadcast back to client - they already have this change
# Broadcasting would cause an infinite loop
logger.info("apply_client_patch: Not broadcasting back to originating client")
elif client_base_seq < entry.seq:
# Conflict! Frontend wins.
# 1. Get backend patches since client_base_seq
backend_patches = []
for seq, p in entry.history:
if seq > client_base_seq:
backend_patches.append(p)
# 2. Apply frontend patch first to the state at client_base_seq
# But we only have the current authoritative model.
# "Apply the frontend patch first to the model (frontend wins)"
# "Re-apply the backend deltas that do not overlap the frontend's changed paths on top"
# Let's get the state as it was at client_base_seq if possible?
# No, history only has patches.
# Alternative: Apply frontend patch to current model.
# Then re-apply backend patches, but discard parts that overlap.
frontend_paths = {p['path'] for p in patch}
current_state = entry.model.model_dump(mode="json")
# Apply frontend patch
new_state = jsonpatch.apply_patch(current_state, patch)
# Re-apply backend patches that don't overlap
for b_patch in backend_patches:
filtered_b_patch = [op for op in b_patch if op['path'] not in frontend_paths]
if filtered_b_patch:
new_state = jsonpatch.apply_patch(new_state, filtered_b_patch)
self._update_model(entry.model, new_state)
# Commit the result as a single new patch
# We need to compute what changed from last_snapshot to new_state
final_patch = jsonpatch.make_patch(entry.last_snapshot, new_state).patch
if final_patch:
entry.commit_patch(final_patch)
# Broadcast resolved state as snapshot to converge
if self.websocket:
msg = SnapshotMessage(
store=entry.store_name,
seq=entry.seq,
state=entry.model.model_dump(mode="json")
)
await self.websocket.send_json(msg.model_dump(mode="json"))
def _update_model(self, model: BaseModel, new_data: Dict[str, Any]):
# Update model using model_validate for potentially nested models
new_model = model.__class__.model_validate(new_data)
for field in model.model_fields:
setattr(model, field, getattr(new_model, field))