12c387a6de
Vier verifizierte Befunde aus dem externen Review (Gemini 3.1 Pro): - app.py: SPA-Fallback gegen Path-Traversal gehaertet (resolve + is_relative_to, liefert nur noch Dateien INNERHALB von frontend/dist aus). - app.py/gateway_proxy.py: geteilter httpx.AsyncClient im lifespan statt neuer Client pro /v1-Anfrage (Keep-Alive/Pooling, spart Sockets unter parallelen Agent-Stroemen). - system.py: check_versions_cached() mit threading.Lock + Double-Check gegen Scan-Stampede. - AGENTS.md: Zeitzonen-Drift korrigiert (Box laeuft Europe/Berlin, nicht UTC). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
118 lines
4.7 KiB
Python
118 lines
4.7 KiB
Python
"""
|
|
Mission Control 2.0 — dünner FastAPI-Einstieg.
|
|
|
|
Hängt die Router ein, liefert (in Prod) das gebaute React-Frontend aus und
|
|
setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev-
|
|
Server (proxyt /api hierher), daher CORS für localhost offen.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
from contextlib import asynccontextmanager
|
|
|
|
import httpx
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import FileResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from starlette.requests import Request
|
|
|
|
from config import FRONTEND_DIST, VERSION
|
|
from routers import agent, connect, console, gateway_proxy, health, maintenance, memory, models, reminders as reminders_router, routing, system, voice
|
|
from services import memory as memory_svc, reminders, sentry, warmer
|
|
|
|
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
|
|
# für alle Module (logging.getLogger(__name__)).
|
|
logging.basicConfig(
|
|
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
|
|
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
|
)
|
|
log = logging.getLogger(__name__)
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""Hintergrund-Tasks an den App-Lebenszyklus binden: Re-Warm-Wächter fürs Agent-Hirn
|
|
+ Health-Wächter (meldet Ausfälle/Erholung in den Lucy-Briefkasten und auf Telegram)."""
|
|
tasks = []
|
|
if warmer.ENABLED:
|
|
tasks.append(asyncio.create_task(warmer.rewarm_loop()))
|
|
log.info("Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)", warmer.INTERVAL)
|
|
if sentry.ENABLED:
|
|
tasks.append(asyncio.create_task(sentry.sentry_loop()))
|
|
tasks.append(asyncio.create_task(reminders.reminders_loop()))
|
|
if memory_svc.AUTO_DEDUPE_ENABLED:
|
|
tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop()))
|
|
log.info("Mem0-Auto-Dedupe aktiv (alle %ss, Schwelle %s)",
|
|
memory_svc.AUTO_DEDUPE_INTERVAL, memory_svc.AUTO_DEDUPE_THRESHOLD)
|
|
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client
|
|
# pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
|
|
app.state.gw_client = httpx.AsyncClient(
|
|
timeout=httpx.Timeout(connect=10.0, read=None, write=None, pool=10.0),
|
|
limits=httpx.Limits(max_keepalive_connections=32, max_connections=64),
|
|
)
|
|
try:
|
|
yield
|
|
finally:
|
|
for task in tasks:
|
|
task.cancel()
|
|
await app.state.gw_client.aclose()
|
|
|
|
|
|
app = FastAPI(title="Mission Control 2.0", version=VERSION, lifespan=lifespan)
|
|
|
|
# Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf.
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def no_cache(request: Request, call_next):
|
|
resp = await call_next(request)
|
|
if request.url.path.startswith("/api"):
|
|
resp.headers["Cache-Control"] = "no-cache"
|
|
return resp
|
|
|
|
|
|
app.include_router(health.router)
|
|
app.include_router(models.router)
|
|
app.include_router(routing.router)
|
|
app.include_router(system.router)
|
|
app.include_router(connect.router)
|
|
app.include_router(memory.router)
|
|
app.include_router(agent.router)
|
|
app.include_router(voice.router) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab)
|
|
app.include_router(reminders_router.router) # Erinnerungen/Routinen (A3) — feuern in den Briefkasten
|
|
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
|
|
app.include_router(maintenance.router)
|
|
app.include_router(console.router) # Box-Konsole (ttyd) same-origin durchreichen — VOR dem SPA-Catch-all
|
|
|
|
|
|
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
|
|
if FRONTEND_DIST.exists():
|
|
app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
|
|
|
|
_DIST_ROOT = FRONTEND_DIST.resolve()
|
|
|
|
@app.get("/{full_path:path}")
|
|
def spa(full_path: str):
|
|
# Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber
|
|
# NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus.
|
|
try:
|
|
target = (FRONTEND_DIST / full_path).resolve()
|
|
if target.is_relative_to(_DIST_ROOT) and target.is_file():
|
|
return FileResponse(target)
|
|
except (ValueError, OSError):
|
|
pass
|
|
|
|
index = FRONTEND_DIST / "index.html"
|
|
if index.exists():
|
|
# index.html nie cachen → Browser zieht nach jedem Deploy das aktuelle (gehashte) Bundle.
|
|
return FileResponse(index, headers={"Cache-Control": "no-cache, must-revalidate"})
|
|
return {"detail": "frontend not built"}
|
|
|