Initial commit: webmail client (M0/M1)
Three-pane webmail client. - Backend: FastAPI + async SQLAlchemy + arq worker (Postgres/Redis), IMAP/SMTP sync, threading, search, drafts/outbox with undo, attachments, 2FA. - Frontend: React 18 + Vite + TypeScript + TanStack Query + Tailwind; three-pane UI with rich-text composer, light/dark themes, resizable panels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
"""Live-sync supervisor (SPEC §6.3).
|
||||
|
||||
Runs inside the arq worker: on startup it launches one IDLE/poll loop per live
|
||||
account so new mail and flag changes flow to the UI over WebSocket. Loops
|
||||
self-heal (reconnect with backoff) and are cancelled on worker shutdown.
|
||||
|
||||
NOTE(spec): accounts that become live *after* worker startup are picked up on the
|
||||
next worker restart; reacting to ``account.state_changed`` live is a later refinement.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import session_scope
|
||||
from app.models import Account
|
||||
from app.sync.engine import live_loop
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_RECONNECT_BACKOFF = 30.0
|
||||
|
||||
|
||||
async def _run_account(account_id: uuid.UUID, stop: asyncio.Event) -> None:
|
||||
while not stop.is_set():
|
||||
try:
|
||||
await live_loop(account_id, "INBOX", stop)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("live loop for account %s crashed; reconnecting", account_id)
|
||||
with contextlib.suppress(TimeoutError):
|
||||
await asyncio.wait_for(stop.wait(), timeout=_RECONNECT_BACKOFF)
|
||||
|
||||
|
||||
async def start_supervisor(ctx: dict[str, Any]) -> None:
|
||||
"""Launch a live loop per live account (called from the worker on_startup)."""
|
||||
async with session_scope() as session:
|
||||
account_ids = list(await session.scalars(select(Account.id).where(Account.state == "live")))
|
||||
stop = asyncio.Event()
|
||||
ctx["supervisor_stop"] = stop
|
||||
ctx["supervisor_tasks"] = [asyncio.create_task(_run_account(aid, stop)) for aid in account_ids]
|
||||
logger.info("live supervisor started for %d account(s)", len(account_ids))
|
||||
|
||||
|
||||
async def stop_supervisor(ctx: dict[str, Any]) -> None:
|
||||
"""Signal and cancel all live loops (called from the worker on_shutdown)."""
|
||||
stop = ctx.get("supervisor_stop")
|
||||
if stop is not None:
|
||||
stop.set()
|
||||
tasks = ctx.get("supervisor_tasks", [])
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
Reference in New Issue
Block a user