Harden CalDAV write-back with retries (#1193)

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
This commit is contained in:
Achilleas90
2026-06-15 09:59:31 +03:00
committed by GitHub
parent 57646300a4
commit ffc0f1dccc
10 changed files with 590 additions and 44 deletions
+44
View File
@@ -1602,6 +1602,7 @@ class CalendarCal(TimestampMixin, Base):
# NULL for local calendars and for CalDAV calendars created before # NULL for local calendars and for CalDAV calendars created before
# multi-account support was added (treated as "use any configured account"). # multi-account support was added (treated as "use any configured account").
account_id = Column(String, nullable=True, index=True) account_id = Column(String, nullable=True, index=True)
caldav_base_url = Column(String, nullable=True)
events = relationship("CalendarEvent", back_populates="calendar", cascade="all, delete-orphan") events = relationship("CalendarEvent", back_populates="calendar", cascade="all, delete-orphan")
@@ -1632,10 +1633,27 @@ class CalendarEvent(TimestampMixin, Base):
# vanishes upstream). NULL/local = created locally (agent, email triage, or # vanishes upstream). NULL/local = created locally (agent, email triage, or
# a UI event whose write-back failed) and must NOT be pruned by the sync. # a UI event whose write-back failed) and must NOT be pruned by the sync.
origin = Column(String, nullable=True, index=True) origin = Column(String, nullable=True, index=True)
remote_href = Column(String, nullable=True) # CalDAV object URL for updates/deletes
remote_etag = Column(String, nullable=True) # Last seen CalDAV ETag, when available
caldav_sync_pending = Column(String, nullable=True) # create | update | delete retry marker
calendar = relationship("CalendarCal", back_populates="events") calendar = relationship("CalendarCal", back_populates="events")
class CalendarDeletedEvent(TimestampMixin, Base):
"""Hidden CalDAV delete tombstone retained until remote delete succeeds."""
__tablename__ = "caldav_deleted_events"
uid = Column(String, primary_key=True, index=True)
owner = Column(String, nullable=True, index=True)
calendar_id = Column(String, nullable=True, index=True)
remote_href = Column(String, nullable=True)
remote_etag = Column(String, nullable=True)
caldav_base_url = Column(String, nullable=True)
summary = Column(String, nullable=True)
last_error = Column(Text, nullable=True)
class Integration(TimestampMixin, Base): class Integration(TimestampMixin, Base):
"""An external service connection (email, RSS, webhook, etc.).""" """An external service connection (email, RSS, webhook, etc.)."""
__tablename__ = "integrations" __tablename__ = "integrations"
@@ -1767,6 +1785,7 @@ def init_db():
_migrate_add_calendar_is_utc() _migrate_add_calendar_is_utc()
_migrate_add_calendar_origin() _migrate_add_calendar_origin()
_migrate_add_calendar_account_id() _migrate_add_calendar_account_id()
_migrate_add_caldav_sync_columns()
_migrate_chat_messages_fts() _migrate_chat_messages_fts()
_migrate_encrypt_email_passwords() _migrate_encrypt_email_passwords()
_migrate_encrypt_signatures() _migrate_encrypt_signatures()
@@ -2067,6 +2086,31 @@ def _migrate_add_calendar_account_id():
pass pass
def _migrate_add_caldav_sync_columns():
"""Add remote CalDAV metadata used for bidirectional sync."""
import sqlite3
db_path = DATABASE_URL.replace("sqlite:///", "")
if not os.path.exists(db_path):
return
try:
conn = sqlite3.connect(db_path)
ev_columns = [row[1] for row in conn.execute("PRAGMA table_info(calendar_events)").fetchall()]
if ev_columns and "remote_href" not in ev_columns:
conn.execute("ALTER TABLE calendar_events ADD COLUMN remote_href TEXT")
if ev_columns and "remote_etag" not in ev_columns:
conn.execute("ALTER TABLE calendar_events ADD COLUMN remote_etag TEXT")
if ev_columns and "caldav_sync_pending" not in ev_columns:
conn.execute("ALTER TABLE calendar_events ADD COLUMN caldav_sync_pending TEXT")
cal_columns = [row[1] for row in conn.execute("PRAGMA table_info(calendars)").fetchall()]
if cal_columns and "caldav_base_url" not in cal_columns:
conn.execute("ALTER TABLE calendars ADD COLUMN caldav_base_url TEXT")
conn.commit()
conn.close()
except Exception as e:
logging.getLogger(__name__).warning(f"CalDAV sync metadata migration failed: {e}")
def _migrate_add_calendar_metadata(): def _migrate_add_calendar_metadata():
"""Add importance/event_type/last_pinged columns to calendar_events table.""" """Add importance/event_type/last_pinged columns to calendar_events table."""
import sqlite3 import sqlite3
+65 -29
View File
@@ -11,7 +11,7 @@ from pydantic import BaseModel
from sqlalchemy import or_, and_ from sqlalchemy import or_, and_
from dateutil.rrule import rrulestr from dateutil.rrule import rrulestr
from core.database import SessionLocal, CalendarCal, CalendarEvent from core.database import SessionLocal, CalendarCal, CalendarDeletedEvent, CalendarEvent
from src.auth_helpers import require_user from src.auth_helpers import require_user
from src.upload_limits import read_upload_limited, ICS_MAX_BYTES from src.upload_limits import read_upload_limited, ICS_MAX_BYTES
@@ -126,6 +126,54 @@ def _resolve_base_uid(uid: str) -> str:
raise ValueError("malformed compound UID: missing base before ::") raise ValueError("malformed compound UID: missing base before ::")
return base return base
async def _push_caldav_event_after_commit(owner: str, uid: str, action: str):
"""Best-effort CalDAV write-through. Local writes stay authoritative if
the remote server is unreachable; pending flags let /sync retry later."""
try:
result = {"ok": True}
if action == "create":
from src.caldav_sync import push_event_create
result = await push_event_create(owner, uid)
elif action == "update":
from src.caldav_sync import push_event_update
result = await push_event_update(owner, uid)
elif action == "delete":
from src.caldav_sync import push_event_delete
result = await push_event_delete(owner, uid)
if result and not result.get("ok") and not result.get("skipped"):
raise RuntimeError(result.get("error") or result)
except Exception as e:
logger.warning("CalDAV %s push failed for uid=%s: %s", action, uid, e)
if action in {"create", "update"}:
db = SessionLocal()
try:
ev = _get_or_404_event(db, uid, owner)
ev.caldav_sync_pending = action
db.commit()
except Exception:
db.rollback()
finally:
db.close()
def _record_caldav_delete_tombstone(db, ev: CalendarEvent, owner: str) -> None:
if not (ev.calendar and ev.calendar.source == "caldav"):
return
tombstone = db.query(CalendarDeletedEvent).filter(
CalendarDeletedEvent.uid == ev.uid,
CalendarDeletedEvent.owner == owner,
).first()
if not tombstone:
tombstone = CalendarDeletedEvent(uid=ev.uid, owner=owner)
db.add(tombstone)
tombstone.calendar_id = ev.calendar_id
tombstone.remote_href = ev.remote_href
tombstone.remote_etag = ev.remote_etag
tombstone.caldav_base_url = getattr(ev.calendar, "caldav_base_url", None)
tombstone.summary = ev.summary or ""
tombstone.last_error = None
# ── Pydantic models ── # ── Pydantic models ──
class EventCreate(BaseModel): class EventCreate(BaseModel):
@@ -843,13 +891,13 @@ def setup_calendar_routes() -> APIRouter:
return {"ok": False, "error": str(e)[:200]} return {"ok": False, "error": str(e)[:200]}
@router.post("/sync") @router.post("/sync")
async def sync_caldav_endpoint(request: Request): async def sync_caldav_endpoint(request: Request, direction: str = "pull"):
"""Pull events from the configured CalDAV server into local DB. """Sync events with the configured CalDAV server.
Returns counts + any per-calendar errors. Called by the frontend Returns counts + any per-calendar errors. Called by the frontend
on calendar open and by the periodic scheduler loop.""" on calendar open and by the periodic scheduler loop."""
owner = _require_user(request) owner = _require_user(request)
from src.caldav_sync import sync_caldav from src.caldav_sync import sync_caldav_direction
return await sync_caldav(owner) return await sync_caldav_direction(owner, direction)
@router.delete("/calendars/{cal_id}") @router.delete("/calendars/{cal_id}")
@@ -1002,19 +1050,12 @@ def setup_calendar_routes() -> APIRouter:
is_utc=_is_utc and not data.all_day, is_utc=_is_utc and not data.all_day,
rrule=data.rrule or "", rrule=data.rrule or "",
color=data.color or None, color=data.color or None,
caldav_sync_pending="create" if cal.source == "caldav" else None,
) )
db.add(ev) db.add(ev)
db.commit() db.commit()
if cal.source == "caldav": if cal.source == "caldav":
# Push the new event to the remote so it appears on the user's await _push_caldav_event_after_commit(owner, uid, "create")
# other devices — the sync is otherwise pull-only (#800).
from src.caldav_writeback import writeback_event
await writeback_event(owner, cal.source, cal.id, {
"uid": uid, "summary": data.summary, "description": data.description,
"location": data.location, "dtstart": dtstart, "dtend": dtend,
"all_day": data.all_day, "is_utc": _is_utc and not data.all_day,
"rrule": data.rrule or "",
})
return {"ok": True, "uid": uid} return {"ok": True, "uid": uid}
except HTTPException: except HTTPException:
raise raise
@@ -1060,15 +1101,12 @@ def setup_calendar_routes() -> APIRouter:
ev.rrule = data.rrule ev.rrule = data.rrule
if data.color is not None: if data.color is not None:
ev.color = data.color if data.color else None ev.color = data.color if data.color else None
is_caldav = ev.calendar and ev.calendar.source == "caldav"
if is_caldav:
ev.caldav_sync_pending = "update"
db.commit() db.commit()
cal = db.query(CalendarCal).filter(CalendarCal.id == ev.calendar_id).first() if is_caldav:
if cal and cal.source == "caldav": await _push_caldav_event_after_commit(owner, base_uid, "update")
from src.caldav_writeback import writeback_event
await writeback_event(owner, cal.source, cal.id, {
"uid": ev.uid, "summary": ev.summary, "description": ev.description,
"location": ev.location, "dtstart": ev.dtstart, "dtend": ev.dtend,
"all_day": ev.all_day, "is_utc": ev.is_utc, "rrule": ev.rrule or "",
})
return {"ok": True} return {"ok": True}
except HTTPException: except HTTPException:
raise raise
@@ -1089,15 +1127,13 @@ def setup_calendar_routes() -> APIRouter:
db = SessionLocal() db = SessionLocal()
try: try:
ev = _get_or_404_event(db, base_uid, owner) ev = _get_or_404_event(db, base_uid, owner)
# Capture what the remote push needs BEFORE the row is gone. is_caldav = ev.calendar and ev.calendar.source == "caldav"
_cal = db.query(CalendarCal).filter(CalendarCal.id == ev.calendar_id).first() if is_caldav:
_is_caldav = bool(_cal and _cal.source == "caldav") _record_caldav_delete_tombstone(db, ev, owner)
_cal_id, _ev_uid = ev.calendar_id, ev.uid
db.delete(ev) db.delete(ev)
db.commit() db.commit()
if _is_caldav: if is_caldav:
from src.caldav_writeback import writeback_event await _push_caldav_event_after_commit(owner, base_uid, "delete")
await writeback_event(owner, "caldav", _cal_id, {"uid": _ev_uid}, delete=True)
return {"ok": True} return {"ok": True}
except HTTPException: except HTTPException:
raise raise
+178 -1
View File
@@ -128,6 +128,17 @@ def validate_caldav_url(raw_url: str) -> str:
return urlunparse(parsed._replace(fragment="")).rstrip("/") return urlunparse(parsed._replace(fragment="")).rstrip("/")
def _event_etag(obj) -> str:
"""Best-effort ETag extraction from python-caldav resources."""
try:
etag = getattr(obj, "etag", None)
if callable(etag):
etag = etag()
return str(etag or "")
except Exception:
return ""
def _stable_cal_id(remote_url: str, owner: str = "", account_id: str = "") -> str: def _stable_cal_id(remote_url: str, owner: str = "", account_id: str = "") -> str:
"""Deterministic local id for a remote CalDAV calendar, scoped to owner """Deterministic local id for a remote CalDAV calendar, scoped to owner
and account so two users or one user with two accounts pointing at and account so two users or one user with two accounts pointing at
@@ -316,11 +327,12 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
color="#5b8abf", color="#5b8abf",
source="caldav", source="caldav",
account_id=account_id or None, account_id=account_id or None,
caldav_base_url=remote_url,
) )
db.add(local_cal) db.add(local_cal)
db.commit() db.commit()
else: else:
# Refresh display name and stamp account_id if missing. # Refresh display name and stamp CalDAV metadata if missing.
changed = False changed = False
if local_cal.name != display_name: if local_cal.name != display_name:
local_cal.name = display_name local_cal.name = display_name
@@ -328,6 +340,9 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
if account_id and not local_cal.account_id: if account_id and not local_cal.account_id:
local_cal.account_id = account_id local_cal.account_id = account_id
changed = True changed = True
if local_cal.caldav_base_url != remote_url:
local_cal.caldav_base_url = remote_url
changed = True
if changed: if changed:
db.commit() db.commit()
result["calendars"] += 1 result["calendars"] += 1
@@ -395,6 +410,9 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
existing = _find_existing_event(db, pending, uid_val, local_cal.id) existing = _find_existing_event(db, pending, uid_val, local_cal.id)
if existing: if existing:
if existing.caldav_sync_pending in {"create", "update"}:
result["events"] += 1
continue
existing.calendar_id = local_cal.id existing.calendar_id = local_cal.id
existing.summary = summary existing.summary = summary
existing.description = description existing.description = description
@@ -405,6 +423,9 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
existing.is_utc = row_is_utc existing.is_utc = row_is_utc
existing.rrule = rrule existing.rrule = rrule
existing.origin = "caldav" existing.origin = "caldav"
existing.remote_href = str(getattr(obj, "url", "") or "") or None
existing.remote_etag = _event_etag(obj) or None
existing.caldav_sync_pending = None
else: else:
new_ev = CalendarEvent( new_ev = CalendarEvent(
uid=uid_val, uid=uid_val,
@@ -418,6 +439,8 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
is_utc=row_is_utc, is_utc=row_is_utc,
rrule=rrule, rrule=rrule,
origin="caldav", origin="caldav",
remote_href=str(getattr(obj, "url", "") or "") or None,
remote_etag=_event_etag(obj) or None,
) )
db.add(new_ev) db.add(new_ev)
pending[uid_val] = new_ev pending[uid_val] = new_ev
@@ -442,6 +465,8 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
CalendarEvent.origin == "caldav", CalendarEvent.origin == "caldav",
CalendarEvent.dtstart >= start, CalendarEvent.dtstart >= start,
CalendarEvent.dtstart <= end, CalendarEvent.dtstart <= end,
CalendarEvent.remote_href.isnot(None),
CalendarEvent.caldav_sync_pending.is_(None),
~CalendarEvent.uid.in_(seen_uids) if seen_uids else CalendarEvent.uid.isnot(None), ~CalendarEvent.uid.in_(seen_uids) if seen_uids else CalendarEvent.uid.isnot(None),
).all() ).all()
for ev in stale: for ev in stale:
@@ -458,6 +483,92 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
return result return result
def _event_payload(ev) -> dict:
return {
"uid": ev.uid,
"summary": ev.summary,
"description": ev.description,
"location": ev.location,
"dtstart": ev.dtstart,
"dtend": ev.dtend,
"all_day": ev.all_day,
"is_utc": ev.is_utc,
"rrule": ev.rrule or "",
}
def _load_event_for_writeback(owner: str, uid: str) -> tuple[str, str, dict] | None:
from core.database import CalendarCal, CalendarEvent, SessionLocal
db = SessionLocal()
try:
ev = (
db.query(CalendarEvent)
.join(CalendarCal)
.filter(CalendarEvent.uid == uid, CalendarCal.owner == owner)
.first()
)
if not ev or not ev.calendar or ev.calendar.source != "caldav":
return None
return ev.calendar.source, ev.calendar.id, _event_payload(ev)
finally:
db.close()
def _load_delete_for_writeback(owner: str, uid: str) -> tuple[str, str, dict] | None:
from core.database import CalendarCal, CalendarDeletedEvent, CalendarEvent, SessionLocal
db = SessionLocal()
try:
tombstone = db.query(CalendarDeletedEvent).filter(
CalendarDeletedEvent.uid == uid,
CalendarDeletedEvent.owner == owner,
).first()
if tombstone:
return "caldav", tombstone.calendar_id, {"uid": uid}
ev = (
db.query(CalendarEvent)
.join(CalendarCal)
.filter(CalendarEvent.uid == uid, CalendarCal.owner == owner)
.first()
)
if not ev or not ev.calendar or ev.calendar.source != "caldav":
return None
return ev.calendar.source, ev.calendar.id, {"uid": uid}
finally:
db.close()
def _pending_writeback_uids(owner: str) -> tuple[list[str], list[str]]:
from core.database import CalendarCal, CalendarDeletedEvent, CalendarEvent, SessionLocal
db = SessionLocal()
try:
rows = (
db.query(CalendarEvent.uid)
.join(CalendarCal)
.filter(
CalendarCal.owner == owner,
CalendarCal.source == "caldav",
CalendarEvent.status != "cancelled",
(
(CalendarEvent.caldav_sync_pending.isnot(None))
| (CalendarEvent.remote_href.is_(None))
),
)
.all()
)
delete_rows = (
db.query(CalendarDeletedEvent.uid)
.filter(CalendarDeletedEvent.owner == owner)
.all()
)
return [row[0] for row in rows], [row[0] for row in delete_rows]
finally:
db.close()
def _load_caldav_accounts(owner: str) -> list: def _load_caldav_accounts(owner: str) -> list:
"""Return the list of CalDAV accounts for *owner*, auto-migrating the legacy """Return the list of CalDAV accounts for *owner*, auto-migrating the legacy
single-account ``caldav`` key to the new ``caldav_accounts`` list on first call. single-account ``caldav`` key to the new ``caldav_accounts`` list on first call.
@@ -533,3 +644,69 @@ async def sync_caldav(owner: str) -> dict:
for err in result.get("errors", []): for err in result.get("errors", []):
totals["errors"].append(f"{label}: {err}") totals["errors"].append(f"{label}: {err}")
return totals return totals
async def push_event_create(owner: str, uid: str) -> dict:
loaded = _load_event_for_writeback(owner, uid)
if not loaded:
return {"ok": True, "skipped": True}
source, calendar_id, payload = loaded
from src.caldav_writeback import writeback_event
return await writeback_event(owner, source, calendar_id, payload)
async def push_event_update(owner: str, uid: str) -> dict:
return await push_event_create(owner, uid)
async def push_event_delete(owner: str, uid: str) -> dict:
loaded = _load_delete_for_writeback(owner, uid)
if not loaded:
return {"ok": True, "skipped": True}
source, calendar_id, payload = loaded
from src.caldav_writeback import writeback_event
return await writeback_event(owner, source, calendar_id, payload, delete=True)
async def push_pending_events(owner: str) -> dict:
result = {"events": 0, "errors": []}
uids, delete_uids = _pending_writeback_uids(owner)
for event_uid in uids:
try:
out = await push_event_update(owner, event_uid)
if out.get("ok"):
result["events"] += 1
elif not out.get("skipped"):
result["errors"].append(f"{event_uid}: {str(out.get('error') or out)[:160]}")
except Exception as e:
logger.warning("CalDAV pending push failed for uid=%s: %s", event_uid, e)
result["errors"].append(f"{event_uid}: {str(e)[:160]}")
for event_uid in delete_uids:
try:
out = await push_event_delete(owner, event_uid)
if out.get("ok"):
result["events"] += 1
elif not out.get("skipped"):
result["errors"].append(f"{event_uid}: {str(out.get('error') or out)[:160]}")
except Exception as e:
logger.warning("CalDAV pending delete failed for uid=%s: %s", event_uid, e)
result["errors"].append(f"{event_uid}: {str(e)[:160]}")
return result
async def sync_caldav_direction(owner: str, direction: str = "pull") -> dict:
direction = (direction or "pull").strip().lower()
if direction == "pull":
return await sync_caldav(owner)
if direction == "push":
return await push_pending_events(owner)
if direction == "both":
pushed = await push_pending_events(owner)
pulled = await sync_caldav(owner)
return {"push": pushed, "pull": pulled}
return {
"calendars": 0,
"events": 0,
"deleted": 0,
"errors": [f"Unsupported CalDAV sync direction: {direction}"],
}
+92 -6
View File
@@ -89,6 +89,23 @@ def find_remote_calendar(calendars, local_cal_id: str, owner: str = "", account_
return None return None
def _resource_href(obj) -> str:
try:
return str(getattr(obj, "url", "") or "")
except Exception:
return ""
def _resource_etag(obj) -> str:
try:
etag = getattr(obj, "etag", None)
if callable(etag):
etag = etag()
return str(etag or "")
except Exception:
return ""
def push_event(calendars, local_cal_id: str, ev: dict, *, delete: bool = False, def push_event(calendars, local_cal_id: str, ev: dict, *, delete: bool = False,
owner: str = "", account_id: str = "") -> dict: owner: str = "", account_id: str = "") -> dict:
"""Create/update (or delete) ``ev`` on the matching remote calendar. """Create/update (or delete) ``ev`` on the matching remote calendar.
@@ -105,6 +122,7 @@ def push_event(calendars, local_cal_id: str, ev: dict, *, delete: bool = False,
remote = find_remote_calendar(calendars, local_cal_id, owner=owner, account_id=account_id) remote = find_remote_calendar(calendars, local_cal_id, owner=owner, account_id=account_id)
if remote is None: if remote is None:
return {"ok": False, "error": "remote calendar not found"} return {"ok": False, "error": "remote calendar not found"}
remote_url = str(getattr(remote, "url", "") or "")
try: try:
existing = remote.event_by_uid(uid) existing = remote.event_by_uid(uid)
@@ -113,17 +131,34 @@ def push_event(calendars, local_cal_id: str, ev: dict, *, delete: bool = False,
if delete: if delete:
if existing is None: if existing is None:
return {"ok": True, "note": "already absent on remote"} return {"ok": True, "note": "already absent on remote", "calendar_url": remote_url}
existing.delete() existing.delete()
return {"ok": True} return {
"ok": True,
"calendar_url": remote_url,
"remote_href": _resource_href(existing),
"remote_etag": _resource_etag(existing),
}
ical = build_event_ical(ev) ical = build_event_ical(ev)
if existing is not None: if existing is not None:
existing.data = ical existing.data = ical
existing.save() existing.save()
return {"ok": True, "updated": True} return {
remote.save_event(ical) "ok": True,
return {"ok": True, "created": True} "updated": True,
"calendar_url": remote_url,
"remote_href": _resource_href(existing),
"remote_etag": _resource_etag(existing),
}
created = remote.save_event(ical)
return {
"ok": True,
"created": True,
"calendar_url": remote_url,
"remote_href": _resource_href(created),
"remote_etag": _resource_etag(created),
}
def _discover_calendars(client): def _discover_calendars(client):
@@ -154,6 +189,54 @@ def _writeback_blocking(local_cal_id, ev, delete, url, username, password,
owner=owner, account_id=account_id) owner=owner, account_id=account_id)
def _persist_writeback_result(owner: str, calendar_id: str, uid: str, result: dict, *, delete: bool) -> None:
from core.database import CalendarCal, CalendarDeletedEvent, CalendarEvent, SessionLocal
if not uid or not isinstance(result, dict):
return
db = SessionLocal()
try:
calendar = db.query(CalendarCal).filter(
CalendarCal.id == calendar_id,
CalendarCal.owner == owner,
).first()
if calendar and result.get("calendar_url"):
calendar.caldav_base_url = result.get("calendar_url")
if delete:
tombstone = db.query(CalendarDeletedEvent).filter(
CalendarDeletedEvent.uid == uid,
CalendarDeletedEvent.owner == owner,
).first()
if result.get("ok"):
if tombstone:
db.delete(tombstone)
elif tombstone:
tombstone.last_error = str(result.get("error") or result)[:500]
db.commit()
return
event = (
db.query(CalendarEvent)
.join(CalendarCal)
.filter(CalendarEvent.uid == uid, CalendarCal.owner == owner)
.first()
)
if event and result.get("ok"):
if result.get("remote_href"):
event.remote_href = result.get("remote_href")
if result.get("remote_etag"):
event.remote_etag = result.get("remote_etag")
event.caldav_sync_pending = None
db.commit()
except Exception:
db.rollback()
logger.exception("CalDAV write-back metadata persistence failed")
finally:
db.close()
async def writeback_event(owner: str, calendar_source: str, calendar_id: str, async def writeback_event(owner: str, calendar_source: str, calendar_id: str,
ev: dict, *, delete: bool = False) -> dict: ev: dict, *, delete: bool = False) -> dict:
"""Best-effort push of a local change to the remote CalDAV server. """Best-effort push of a local change to the remote CalDAV server.
@@ -204,9 +287,12 @@ async def writeback_event(owner: str, calendar_source: str, calendar_id: str,
result = await asyncio.to_thread( result = await asyncio.to_thread(
_writeback_blocking, calendar_id, ev, delete, url, user, pw, owner, acc_id _writeback_blocking, calendar_id, ev, delete, url, user, pw, owner, acc_id
) )
_persist_writeback_result(owner, calendar_id, (ev or {}).get("uid", ""), result, delete=delete)
if not result.get("ok"): if not result.get("ok"):
logger.warning("CalDAV write-back did not apply: %s", result.get("error") or result) logger.warning("CalDAV write-back did not apply: %s", result.get("error") or result)
return result return result
except Exception as e: except Exception as e:
logger.exception("CalDAV write-back raised") logger.exception("CalDAV write-back raised")
return {"ok": False, "error": str(e)[:200]} result = {"ok": False, "error": str(e)[:200]}
_persist_writeback_result(owner, calendar_id, (ev or {}).get("uid", ""), result, delete=delete)
return result
+22 -1
View File
@@ -1445,7 +1445,15 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
"""Handle manage_calendar tool calls: list/create/update/delete calendar events (local SQLite).""" """Handle manage_calendar tool calls: list/create/update/delete calendar events (local SQLite)."""
from datetime import datetime, timedelta from datetime import datetime, timedelta
from core.database import SessionLocal, CalendarCal, CalendarEvent, Note from core.database import SessionLocal, CalendarCal, CalendarEvent, Note
from routes.calendar_routes import _ensure_default_calendar, _parse_dt, _parse_dt_pair, parse_due_for_user, _resolve_base_uid from routes.calendar_routes import (
_ensure_default_calendar,
_parse_dt,
_parse_dt_pair,
parse_due_for_user,
_resolve_base_uid,
_push_caldav_event_after_commit,
_record_caldav_delete_tombstone,
)
import uuid as _uuid import uuid as _uuid
try: try:
@@ -1825,6 +1833,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
rrule=args.get("rrule", "") or "", rrule=args.get("rrule", "") or "",
event_type=event_type, event_type=event_type,
importance=importance, importance=importance,
caldav_sync_pending="create" if cal.source == "caldav" else None,
) )
db.add(ev) db.add(ev)
reminder_note_id = None reminder_note_id = None
@@ -1839,6 +1848,8 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
dtstart_is_utc and not all_day, dtstart_is_utc and not all_day,
) )
db.commit() db.commit()
if cal.source == "caldav":
await _push_caldav_event_after_commit(owner, uid, "create")
tag_blurb = f" [{event_type}]" if event_type else "" tag_blurb = f" [{event_type}]" if event_type else ""
if minutes_before is None: if minutes_before is None:
reminder_blurb = "" reminder_blurb = ""
@@ -1896,7 +1907,12 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
ev.event_type = _tag or None ev.event_type = _tag or None
if args.get("importance") is not None: if args.get("importance") is not None:
ev.importance = args["importance"] ev.importance = args["importance"]
is_caldav = ev.calendar and ev.calendar.source == "caldav"
if is_caldav:
ev.caldav_sync_pending = "update"
db.commit() db.commit()
if is_caldav:
await _push_caldav_event_after_commit(owner, base_uid, "update")
return {"response": f"Updated event {uid}", "exit_code": 0} return {"response": f"Updated event {uid}", "exit_code": 0}
elif action == "delete_event": elif action == "delete_event":
@@ -1910,8 +1926,13 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
ev = _event_query().filter(CalendarEvent.uid == base_uid).first() ev = _event_query().filter(CalendarEvent.uid == base_uid).first()
if not ev: if not ev:
return {"error": f"Event {uid} not found", "exit_code": 1} return {"error": f"Event {uid} not found", "exit_code": 1}
is_caldav = ev.calendar and ev.calendar.source == "caldav" and ev.remote_href
if is_caldav:
_record_caldav_delete_tombstone(db, ev, owner)
db.delete(ev) db.delete(ev)
db.commit() db.commit()
if is_caldav:
await _push_caldav_event_after_commit(owner, base_uid, "delete")
return {"response": f"Deleted event {uid}", "exit_code": 0} return {"response": f"Deleted event {uid}", "exit_code": 0}
else: else:
+169
View File
@@ -0,0 +1,169 @@
"""Regression coverage for bidirectional CalDAV sync plumbing.
These tests avoid a live CalDAV server. They pin the local invariants that keep
Odysseus-created CalDAV events from being pruned before they can be pushed.
"""
from datetime import datetime
import importlib.util
from pathlib import Path
import sys
from src.caldav_writeback import build_event_ical
def test_event_to_ical_serializes_core_fields_and_rrule():
ical = build_event_ical({
"uid": "evt-123",
"summary": "Planning",
"description": "Bring notes",
"location": "HQ",
"dtstart": datetime(2026, 6, 5, 9, 0),
"dtend": datetime(2026, 6, 5, 10, 0),
"all_day": False,
"is_utc": False,
"rrule": "FREQ=WEEKLY;COUNT=2",
})
assert "UID:evt-123" in ical
assert "SUMMARY:Planning" in ical
assert "DESCRIPTION:Bring notes" in ical
assert "LOCATION:HQ" in ical
assert "RRULE:FREQ=WEEKLY;COUNT=2" in ical
def test_caldav_pull_prune_skips_unsynced_or_pending_local_rows():
source = Path("src/caldav_sync.py").read_text()
assert 'existing.caldav_sync_pending in {"create", "update"}' in source
assert "CalendarEvent.remote_href.isnot(None)" in source
assert "CalendarEvent.caldav_sync_pending.is_(None)" in source
def test_http_calendar_writes_mark_pending_and_push_after_commit():
source = Path("routes/calendar_routes.py").read_text()
assert 'caldav_sync_pending="create" if cal.source == "caldav" else None' in source
assert 'ev.caldav_sync_pending = "update"' in source
assert 'await _push_caldav_event_after_commit(owner, uid, "create")' in source
assert 'await _push_caldav_event_after_commit(owner, base_uid, "update")' in source
assert 'await _push_caldav_event_after_commit(owner, base_uid, "delete")' in source
assert "_record_caldav_delete_tombstone(db, ev, owner)" in source
assert 'not result.get("ok")' in source
def test_agent_calendar_writes_share_caldav_push_path():
source = Path("src/tool_implementations.py").read_text()
assert "_push_caldav_event_after_commit" in source
assert 'caldav_sync_pending="create" if cal.source == "caldav" else None' in source
assert 'ev.caldav_sync_pending = "update"' in source
assert 'await _push_caldav_event_after_commit(owner, uid, "create")' in source
assert 'await _push_caldav_event_after_commit(owner, base_uid, "update")' in source
assert 'await _push_caldav_event_after_commit(owner, base_uid, "delete")' in source
assert "_record_caldav_delete_tombstone(db, ev, owner)" in source
def test_database_declares_and_migrates_caldav_remote_metadata():
source = Path("core/database.py").read_text()
for needle in [
"class CalendarDeletedEvent",
"remote_href = Column(String, nullable=True)",
"remote_etag = Column(String, nullable=True)",
"caldav_sync_pending = Column(String, nullable=True)",
"caldav_base_url = Column(String, nullable=True)",
"ALTER TABLE calendar_events ADD COLUMN remote_href TEXT",
"ALTER TABLE calendar_events ADD COLUMN remote_etag TEXT",
"ALTER TABLE calendar_events ADD COLUMN caldav_sync_pending TEXT",
"ALTER TABLE calendars ADD COLUMN caldav_base_url TEXT",
"_migrate_add_caldav_sync_columns()",
]:
assert needle in source
def test_failed_remote_delete_leaves_tombstone_and_later_retry_cleans_up(tmp_path, monkeypatch):
import src.caldav_writeback as writeback
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path / 'calendar.db'}")
spec = importlib.util.spec_from_file_location("core.database", Path("core/database.py"))
dbmod = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, "core.database", dbmod)
spec.loader.exec_module(dbmod)
CalendarCal = dbmod.CalendarCal
CalendarDeletedEvent = dbmod.CalendarDeletedEvent
CalendarEvent = dbmod.CalendarEvent
TestingSessionLocal = dbmod.SessionLocal
session = TestingSessionLocal()
try:
cal = CalendarCal(
id="caldav-test",
owner="alice",
name="Remote",
source="caldav",
caldav_base_url="https://caldav.example/calendars/alice/main/",
)
ev = CalendarEvent(
uid="evt-delete",
calendar_id=cal.id,
summary="Delete me",
dtstart=datetime(2026, 6, 5, 9, 0),
dtend=datetime(2026, 6, 5, 10, 0),
remote_href="https://caldav.example/calendars/alice/main/evt-delete.ics",
)
session.add(cal)
session.add(ev)
session.commit()
tombstone = CalendarDeletedEvent(
uid=ev.uid,
owner="alice",
calendar_id=ev.calendar_id,
remote_href=ev.remote_href,
remote_etag=ev.remote_etag,
caldav_base_url=cal.caldav_base_url,
summary=ev.summary,
)
session.add(tombstone)
session.delete(ev)
session.commit()
assert session.query(CalendarEvent).filter_by(uid="evt-delete").first() is None
tombstone = session.query(CalendarDeletedEvent).filter_by(uid="evt-delete").first()
assert tombstone is not None
assert tombstone.remote_href.endswith("evt-delete.ics")
finally:
session.close()
writeback._persist_writeback_result(
"alice",
"caldav-test",
"evt-delete",
{"ok": False, "error": "temporary remote delete failure"},
delete=True,
)
session = TestingSessionLocal()
try:
tombstone = session.query(CalendarDeletedEvent).filter_by(uid="evt-delete").first()
assert tombstone is not None
assert "temporary remote delete failure" in tombstone.last_error
finally:
session.close()
writeback._persist_writeback_result(
"alice",
"caldav-test",
"evt-delete",
{"ok": True},
delete=True,
)
session = TestingSessionLocal()
try:
assert session.query(CalendarDeletedEvent).filter_by(uid="evt-delete").first() is None
assert session.query(CalendarEvent).filter_by(uid="evt-delete").first() is None
finally:
session.close()
+9 -1
View File
@@ -22,7 +22,9 @@ CAL_ID = _stable_cal_id(REMOTE_URL)
class FakeEvent: class FakeEvent:
def __init__(self): def __init__(self, url="https://p69-caldav.icloud.com/123/calendars/home/evt-1.ics"):
self.url = url
self.etag = '"abc123"'
self.data = "OLD" self.data = "OLD"
self.saved = False self.saved = False
self.deleted = False self.deleted = False
@@ -39,6 +41,7 @@ class FakeCalendar:
self.url = url self.url = url
self._existing = existing self._existing = existing
self.saved_ical = None self.saved_ical = None
self.created = FakeEvent(str(url).rstrip("/") + "/created.ics")
def event_by_uid(self, uid): def event_by_uid(self, uid):
if self._existing is None: if self._existing is None:
@@ -47,6 +50,7 @@ class FakeCalendar:
def save_event(self, ical): def save_event(self, ical):
self.saved_ical = ical self.saved_ical = ical
return self.created
def _ev(**over): def _ev(**over):
@@ -91,6 +95,8 @@ def test_push_create_calls_save_event():
res = push_event([cal], CAL_ID, _ev(), delete=False) res = push_event([cal], CAL_ID, _ev(), delete=False)
assert res["ok"] and res.get("created") assert res["ok"] and res.get("created")
assert cal.saved_ical and "UID:evt-1" in cal.saved_ical assert cal.saved_ical and "UID:evt-1" in cal.saved_ical
assert res["calendar_url"] == REMOTE_URL
assert res["remote_href"].endswith("/created.ics")
def test_push_update_overwrites_existing(): def test_push_update_overwrites_existing():
@@ -100,6 +106,8 @@ def test_push_update_overwrites_existing():
assert res["ok"] and res.get("updated") assert res["ok"] and res.get("updated")
assert existing.saved and "SUMMARY:Moved" in existing.data assert existing.saved and "SUMMARY:Moved" in existing.data
assert cal.saved_ical is None # used update path, not create assert cal.saved_ical is None # used update path, not create
assert res["remote_href"].endswith("evt-1.ics")
assert res["remote_etag"] == '"abc123"'
def test_push_delete_removes_existing(): def test_push_delete_removes_existing():
+9 -5
View File
@@ -20,7 +20,7 @@ from sqlalchemy.pool import NullPool
import core.database as cdb import core.database as cdb
import routes.calendar_routes as croutes import routes.calendar_routes as croutes
import src.caldav_writeback as wb import src.caldav_sync as csync
from core.database import CalendarCal from core.database import CalendarCal
from routes.calendar_routes import EventCreate from routes.calendar_routes import EventCreate
@@ -39,11 +39,16 @@ croutes.SessionLocal = _TS
def calls(monkeypatch): def calls(monkeypatch):
recorded = [] recorded = []
async def _fake_writeback(owner, source, cal_id, ev, *, delete=False): async def _fake_create(owner, uid):
recorded.append({"source": source, "cal_id": cal_id, "uid": ev.get("uid"), "delete": delete}) recorded.append({"uid": uid, "delete": False, "action": "create"})
return {"ok": True} return {"ok": True}
monkeypatch.setattr(wb, "writeback_event", _fake_writeback) async def _fake_delete(owner, uid):
recorded.append({"uid": uid, "delete": True, "action": "delete"})
return {"ok": True}
monkeypatch.setattr(csync, "push_event_create", _fake_create)
monkeypatch.setattr(csync, "push_event_delete", _fake_delete)
return recorded return recorded
@@ -77,7 +82,6 @@ async def test_create_on_caldav_calendar_pushes_to_remote(calls):
summary="Dentist", dtstart="2026-06-10T14:00:00Z", calendar_href=cal_id)) summary="Dentist", dtstart="2026-06-10T14:00:00Z", calendar_href=cal_id))
assert res["ok"] is True assert res["ok"] is True
assert len(calls) == 1 assert len(calls) == 1
assert calls[0]["source"] == "caldav" and calls[0]["cal_id"] == cal_id
assert calls[0]["delete"] is False assert calls[0]["delete"] is False
+1
View File
@@ -151,6 +151,7 @@ def _install_calendar_db_stub(monkeypatch):
db = types.ModuleType("core.database") db = types.ModuleType("core.database")
db.SessionLocal = MagicMock() db.SessionLocal = MagicMock()
db.CalendarCal = _CalendarCal db.CalendarCal = _CalendarCal
db.CalendarDeletedEvent = MagicMock()
db.CalendarEvent = _CalendarEvent db.CalendarEvent = _CalendarEvent
for name in [ for name in [
"Base", "Base",
+1 -1
View File
@@ -28,7 +28,7 @@ from unittest.mock import MagicMock
def _null_owner_stubs(monkeypatch): def _null_owner_stubs(monkeypatch):
for _stub, _attrs in ( for _stub, _attrs in (
("core.database", ( ("core.database", (
"Base", "SessionLocal", "CalendarCal", "CalendarEvent", "Base", "SessionLocal", "CalendarCal", "CalendarDeletedEvent", "CalendarEvent",
"Document", "DocumentVersion", "Session", "ChatMessage", "Document", "DocumentVersion", "Session", "ChatMessage",
"GalleryImage", "GalleryAlbum", "Note", "ScheduledTask", "GalleryImage", "GalleryAlbum", "Note", "ScheduledTask",
"TaskRun", "ModelEndpoint", "Webhook", "TaskRun", "ModelEndpoint", "Webhook",