from contextlib import asynccontextmanager import logging from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.api.middleware.rate_limit import TierRateLimitMiddleware from app.api.middleware.sanitizer import SanitizerMiddleware from app.config.settings import settings logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) logging.getLogger("sqlalchemy.pool").setLevel(logging.WARNING) async def _memory_audit_cron_tick() -> None: """Weekly cron: contradiction scan + label canonicalization for all users (Phase 7).""" import logging # noqa: PLC0415 _log = logging.getLogger(__name__) _log.info("memory audit cron tick: starting") try: from app.db import async_session # noqa: PLC0415 from app.core.memory_maintenance import audit_memory # noqa: PLC0415 from app.models import User # noqa: PLC0415 from sqlalchemy import select # noqa: PLC0415 async with async_session() as db: result = await db.execute(select(User.id)) user_ids: list[str] = list(result.scalars().all()) for uid in user_ids: try: async with async_session() as db: await audit_memory(db, uid) except Exception as exc: _log.warning("memory audit cron tick: audit_memory failed user=%s: %s", uid, exc) _log.info("memory audit cron tick: done users=%d", len(user_ids)) except Exception as exc: _log.warning("memory audit cron tick: failed: %s", exc) async def _memory_cron_tick() -> None: """Hourly cron: drain Free-tier extraction queue + mine proactive patterns for Power+ users.""" import logging # noqa: PLC0415 _log = logging.getLogger(__name__) _log.info("memory cron tick: starting") try: from app.db import async_session # noqa: PLC0415 from app.core.memory_maintenance import drain_extraction_queue, mine_proactive_patterns # noqa: PLC0415 from app.billing.tier_manager import tier_manager # noqa: PLC0415 from app.models import User # noqa: PLC0415 from sqlalchemy import select # noqa: PLC0415 async with async_session() as db: await drain_extraction_queue(db) # mine proactive patterns for every Power+ user async with async_session() as db: result = await db.execute(select(User.id)) user_ids: list[str] = list(result.scalars().all()) for uid in user_ids: try: async with async_session() as db: tier = await tier_manager.get_tier(uid, db) if tier_manager.check_feature(tier, "proactive_mining"): await mine_proactive_patterns(db, uid) except Exception as exc: _log.warning("memory cron tick: mine_proactive_patterns failed user=%s: %s", uid, exc) _log.info("memory cron tick: done users=%d", len(user_ids)) except Exception as exc: _log.warning("memory cron tick: failed: %s", exc) async def _scout_cron_tick() -> None: """Every-15-min cron: poll enabled cloud scouts (cron-fallback; push is primary). Skips any scout whose ``last_run_at`` is within the last 5 minutes so a push notification and the fallback cron don't double-fire within the same window. """ import logging # noqa: PLC0415 import uuid # noqa: PLC0415 from datetime import datetime, timezone # noqa: PLC0415 _log = logging.getLogger(__name__) _log.info("scout cron tick: starting") try: from app.db import async_session # noqa: PLC0415 from app.models import CloudScoutConfig # noqa: PLC0415 from app.scouts.engine import ScoutEngine # noqa: PLC0415 from sqlalchemy import select # noqa: PLC0415 async with async_session() as session: scouts = (await session.execute( select(CloudScoutConfig).where(CloudScoutConfig.enabled == True) # noqa: E712 )).scalars().all() engine = ScoutEngine() triggered = 0 for scout in scouts: # Rate-limit guard: push is primary; skip if ran within 5 minutes. if scout.last_run_at: elapsed = (datetime.now(tz=timezone.utc) - scout.last_run_at).total_seconds() if elapsed < 300: continue try: await engine.trigger_scout(uuid.UUID(str(scout.id))) triggered += 1 except Exception as exc: _log.warning("scout cron tick: trigger failed scout=%s: %s", scout.id, exc) _log.info("scout cron tick: done triggered=%d total=%d", triggered, len(scouts)) except Exception as exc: _log.warning("scout cron tick: failed: %s", exc) async def _scout_watch_renewal_tick() -> None: """Every-24-hour cron: re-issue Gmail users.watch for scouts expiring within 24h. Handles missing or misconfigured connectors gracefully — logs and continues. """ import logging # noqa: PLC0415 from datetime import datetime, timedelta, timezone # noqa: PLC0415 _log = logging.getLogger(__name__) _log.info("scout watch renewal tick: starting") try: from app.db import async_session # noqa: PLC0415 from app.models import CloudScoutConfig # noqa: PLC0415 from app.scouts.connectors.registry import get_connector # noqa: PLC0415 from sqlalchemy import select # noqa: PLC0415 threshold = datetime.now(tz=timezone.utc) + timedelta(hours=24) renewed = 0 async with async_session() as session: scouts = (await session.execute( select(CloudScoutConfig).where( CloudScoutConfig.enabled == True, # noqa: E712 CloudScoutConfig.provider == "gmail", CloudScoutConfig.gmail_watch_expires_at <= threshold, ) )).scalars().all() for scout in scouts: try: connector = get_connector("gmail") await connector.renew_watch(scout) renewed += 1 except Exception: _log.exception("scout watch renewal tick: renew failed scout=%s", scout.id) await session.commit() _log.info("scout watch renewal tick: done renewed=%d", renewed) except Exception as exc: _log.warning("scout watch renewal tick: failed: %s", exc) @asynccontextmanager async def lifespan(app: FastAPI): # Startup: register source connectors. from app.scouts.connectors.gmail import GmailConnector # noqa: PLC0415 from app.scouts.connectors.registry import register_connector # noqa: PLC0415 register_connector(GmailConnector()) # Startup: ensure agent tool modules are loaded. import app.agents # noqa: F401 scheduler = None if settings.SCHEDULER_ENABLED: from apscheduler.schedulers.asyncio import AsyncIOScheduler # noqa: PLC0415 scheduler = AsyncIOScheduler() scheduler.add_job(_memory_cron_tick, "interval", hours=1, id="memory_cron") scheduler.add_job(_memory_audit_cron_tick, "interval", weeks=1, id="memory_audit_cron") scheduler.add_job( _scout_cron_tick, "interval", minutes=15, id="scout_cron_tick", replace_existing=True, ) scheduler.add_job( _scout_watch_renewal_tick, "interval", hours=24, id="scout_watch_renewal_tick", replace_existing=True, ) scheduler.start() logging.getLogger(__name__).info("memory cron scheduler started (interval=1h)") yield if scheduler is not None: scheduler.shutdown(wait=False) # Shutdown: dispose SQLAlchemy connection pool from app.db import engine await engine.dispose() def create_app() -> FastAPI: app = FastAPI( title="AdiuvAI Cloud API", version="0.1.0", docs_url="/docs" if settings.ENV == "dev" else None, redoc_url=None, lifespan=lifespan, ) app.add_middleware( CORSMiddleware, allow_origins=settings.CORS_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Middleware stack (Starlette inserts at position 0, so last-added = outermost). # Request flow: TierRateLimit → Sanitizer → CORS → Router # Response flow: Router → CORS → Sanitizer → TierRateLimit app.add_middleware(SanitizerMiddleware) app.add_middleware(TierRateLimitMiddleware) from app.api.routes import scouts, auth, billing, chat, device_ws, memory, scout_webhooks app.include_router(auth.router, prefix="/api/v1") app.include_router(chat.router, prefix="/api/v1") app.include_router(billing.router, prefix="/api/v1") app.include_router(scouts.router, prefix="/api/v1") app.include_router(scout_webhooks.router, prefix="/api/v1") app.include_router(device_ws.router, prefix="/api/v1") app.include_router(memory.router, prefix="/api/v1") @app.get("/api/v1/health", tags=["health"]) async def health() -> dict: return {"status": "ok", "version": app.version} return app app = create_app()