Terminal / main.py
Baida-A's picture
multi-sync: 171 file from Baida98/AI@9b697a97 (2026-07-05 14:21)
02f6342 verified
Raw
History Blame Contribute Delete
22.5 kB
"""
backend/main.py — FastAPI entry point (S354: split in APIRouter modules).
Mappa dei router:
api.conversations → /api/conversations/**
api.agent_memory → /api/memory/agent/**
api.files → /api/files/**
api.agent → /api/agent/**, /api/reason/loop, /api/unified/loop, /run_loop
api.exec → /api/exec, /api/execute-shell, /api/pip-install, /api/agent/fix
api.search → /api/search, /api/fetch-page, /api/analyze-image
api.providers → /health, /api/tools, /api/status, /api/ai/health, /api/providers/heartbeat
api.vault → /api/vault/**
api.webhook → /api/webhook/**, /api/public/chat
api.terminal → /ws/terminal, GET /api/terminal/packages
api.browser → /api/browser/** (pre-esistente)
api.coding → /api/coding/** (pre-esistente)
api.web → /web/** (pre-esistente)
api.mcp → /api/mcp (P19-B3: MCP JSON-RPC 2.0 server)
"""
import os, time, logging, secrets as _secrets_mod
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.responses import JSONResponse
# Gap 2.2: Structured JSON logging — setup PRIMA di qualsiasi altro import
from api.structured_log import setup_structured_logging as _setup_structured_log, router as _logs_router
from api.integrity_manager import router as _integrity_router
from api.state import get_env_secret # P41
_setup_structured_log()
import logging as _boot_logger; _boot_logger.getLogger('agente_ai').info('BOOT: importing FastAPI...')
app = FastAPI(title='Agente AI', version='3.6.0')
_logger = logging.getLogger('agente_ai')
# S274-SEC3: INTERNAL_TOKEN — genera casuale al boot se non configurato.
_GENERATED_TOKEN = _secrets_mod.token_hex(32)
if not get_env_secret('INTERNAL_TOKEN'):
os.environ['INTERNAL_TOKEN'] = _GENERATED_TOKEN
_logger.critical('BOOT: INTERNAL_TOKEN not set — ephemeral token generato per questa sessione.')
_logger.critical('BOOT: Ogni restart cambia il token → CF Worker riceve 401 finché il secret non è aggiornato!')
_logger.critical('BOOT: session token (copia in CF Workers secret INTERNAL_TOKEN): %r', _GENERATED_TOKEN)
_logger.critical('BOOT: Fix — imposta INTERNAL_TOKEN uguale su HF Spaces e CF Workers secrets.')
else:
_logger.info('BOOT: INTERNAL_TOKEN configurato OK')
# ── CORS — env-driven, Safari-safe ───────────────────────────────────────────
# ALLOWED_ORIGINS: comma-separated list, e.g. "https://agente-ai.vercel.app,http://localhost:5173"
# Supports wildcard suffix match (*.vercel.app, *.hf.space) for preview URLs.
_ALLOWED_ORIGINS_ENV = os.getenv('ALLOWED_ORIGINS', '')
# GAP-CORS-LOCALHOST-PROD: localhost vuoto in ENV=production
_ALWAYS_ALLOWED = (
[] if os.getenv('ENV', 'dev') == 'production' else [
'http://localhost:5173',
'http://localhost:4173',
'http://localhost:3000',
]
)
_VERCEL_PATTERNS = ['.vercel.app', '.vercel.sh', '.pages.dev']
_HF_PATTERNS = ['.hf.space', '.huggingface.co']
def _build_origin_list() -> list[str]:
origins = list(_ALWAYS_ALLOWED)
if _ALLOWED_ORIGINS_ENV:
for o in _ALLOWED_ORIGINS_ENV.split(','):
o = o.strip()
if o:
origins.append(o)
return origins
_STATIC_ORIGINS = _build_origin_list()
def _is_allowed_origin(origin: str | None) -> bool:
if not origin:
return False
if origin in _STATIC_ORIGINS:
_logger.info('CORS ALLOW (static): %s', origin)
return True
for pat in _VERCEL_PATTERNS + _HF_PATTERNS:
if origin.endswith(pat):
_logger.info('CORS ALLOW (pattern %s): %s', pat, origin)
return True
_logger.warning('CORS BLOCK: %s', origin)
return False
# CORS NOTE (HF Spaces 2026-05-27): HF proxy auto-injects ACAO on all responses.
# This middleware handles preflight OPTIONS for Vercel frontend + documents allowed patterns.
_CORS_HEADERS = {
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS,PATCH',
'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '3600',
'Vary': 'Origin',
}
@app.middleware('http')
async def _cors_middleware(request: Request, call_next):
origin = request.headers.get('origin')
if request.method == 'OPTIONS':
if _is_allowed_origin(origin):
return JSONResponse(content={}, status_code=204, headers={
'Access-Control-Allow-Origin': origin,
**_CORS_HEADERS,
})
return JSONResponse(content={}, status_code=204)
response = await call_next(request)
if _is_allowed_origin(origin):
response.headers['Access-Control-Allow-Origin'] = origin
for k, v in _CORS_HEADERS.items():
response.headers[k] = v
return response
@app.options('/{path:path}')
async def _preflight_fallback(path: str, request: Request):
origin = request.headers.get('origin', '')
if not _is_allowed_origin(origin):
return JSONResponse(content={}, status_code=204)
return JSONResponse(content={}, status_code=204, headers={
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS,PATCH',
'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '3600',
'Vary': 'Origin',
})
# ── WARN-1 fix: body size hard limit — S292 ─────────────────────────────────
_MAX_BODY_BYTES = 512_000 # 512 KB — increased for PDF/vision content (V006)
@app.middleware('http')
async def _body_size_middleware(request: Request, call_next):
cl = request.headers.get('content-length')
if cl:
try:
if int(cl) > _MAX_BODY_BYTES:
return JSONResponse(
{'detail': f'Payload troppo grande: max {_MAX_BODY_BYTES // 1024}KB. (R-S292)'},
status_code=413,
)
except ValueError:
pass
return await call_next(request)
# ── S477-SEC4: Rate limiting in-memory per IP ────────────────────────────────
# VITE_INTERNAL_TOKEN è visibile nel bundle JS → rate limiting come mitigazione
# pratica all'abuso (CF Worker proxy è il fix definitivo, non ancora implementato).
# 120 req/min globale per IP; OPTIONS exempt (preflight CORS non contano).
# In-memory: si resetta a ogni restart HF Space — accettabile su free tier.
_rl_store: dict[str, list[float]] = {}
_RL_WINDOW = 60.0
_RL_LIMIT = 120 # req/minuto per IP
@app.middleware('http')
async def _rate_limit_middleware(request: Request, call_next):
if request.method == "OPTIONS":
return await call_next(request)
ip = (request.client.host if request.client else None) or "unknown"
now = time.monotonic()
hits = [t for t in _rl_store.get(ip, []) if now - t < _RL_WINDOW]
if len(hits) >= _RL_LIMIT:
return JSONResponse(
{"detail": "Too many requests"},
status_code=429,
headers={
"X-RateLimit-Limit": str(_RL_LIMIT),
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": str(int(now + _RL_WINDOW)),
"Retry-After": str(int(_RL_WINDOW)),
},
)
hits.append(now)
_rl_store[ip] = hits
# S572: prune _rl_store ogni ~500 req — evita leak memoria con molti IP unici.
# Rimuove IP con zero hit nella finestra (inattivi da > _RL_WINDOW secondi).
if len(_rl_store) > 500:
_cutoff = now - _RL_WINDOW
_stale = [_k for _k, _v in list(_rl_store.items()) if not _v or _v[-1] < _cutoff]
for _k in _stale:
_rl_store.pop(_k, None)
return await call_next(request)
# ── Include routers (S354 split) ─────────────────────────────────────────────
from api.conversations import router as _conv_router
from api.agent_memory import router as _mem_router
from api.files import router as _files_router
from api.agent import router as _agent_router
from api.exec import router as _exec_router
from api.search import router as _search_router
from api.providers import router as _providers_router
from api.vault import router as _vault_router
from api.webhook import router as _webhook_router
from api.terminal import router as _terminal_router
from api.browser import router as _browser_router
from api.coding import router as _coding_router
from api.web import router as _web_router
from api.vision import router as _vision_router # V001: generate_image / analyze_image / search_images
from api.gemini_vision import router as _gemini_vision_router # P48: Gemini 1.5 Flash Vision direct endpoint
from api.email import router as _email_router # V002: send_email via Resend API
from api.database import router as _db_router # V003: database_query PostgreSQL/SQLite
from api.research import router as _research_router # V004: web_research multi-URL + Groq synthesis
from api.deploy import router as _deploy_router # S750: CI status + deploy trigger
from api.scheduler import router as _scheduler_router, start_scheduler as _start_scheduler # GAP-2.1: server-side persistent scheduler
from api.benchmark import router as _benchmark_router # S-BENCH: self-test endpoint /api/debug/benchmark
from api.telemetry import router as _telemetry_router # BG-3: timing metrics /api/telemetry
from api.agent_telemetry import router as _agent_telemetry_router # Gap N4: verdetti cross-session
from api.telegram_webhook import router as _tg_webhook_router # TG-BOT: riceve comandi bot + setup webhook
# notify_bot rimosso — Telegram gestito dal daemon Node.js
from api.incident_registry import router as _incident_router, start_incident_registry as _start_incident_reg # GAP-A1
from api.decision_memory import router as _decision_router, start_decision_memory as _start_decision_mem # GAP-A2
from api.llm_cache import router as _cache_router # Gap 2.3: /api/cache/stats
from api.daemon_status import router as _daemon_status_router # DAEMON-STATUS: /api/daemon/status
from api.auth_guard import AuthRole, require_role # GAP-A6: importa per uso nei router
from api.blackboard import router as _blackboard_router # S-BB: shared blackboard cross-agent via Upstash
from api.state_sync import router as _state_sync_router # S901: UltraVSS Beta Strategy
from api.job_queue import router as _jq_router # S-DUAL-2: /api/jq/** Redis coordination
from agents.skill_tracker import skill_router as _skill_tracker_router # P17-B2: POST /skill-record + DELETE /skill-stats
from api.mcp import router as _mcp_router # P19-B3: MCP JSON-RPC 2.0 server
from api.ads_manager import router as _ads_router # ADS-MANAGER: /api/ads/**
try:
from service_handlers.semantic_memory_routes import router as _sem_mem_router # S766-MEM
_has_sem_mem = True
except ImportError:
_has_sem_mem = False
from api.auth_managed import router as _auth_managed_router # P38: OAuth one-click connectors
# Doc2-1b-FIX: memory/sync router non era montato — endpoint /api/memory/sync/* non raggiungibili
# NOTA: create_memory_sync_router(memory) è una factory — richiede l'istanza MemoryManager.
# GAP-5-FIX: memory/sync router montato in _on_startup() (vedi sotto)
app.include_router(_auth_managed_router) # P38: OAuth one-click
app.include_router(_conv_router)
app.include_router(_mem_router)
app.include_router(_files_router)
app.include_router(_agent_router)
app.include_router(_exec_router)
app.include_router(_search_router)
app.include_router(_providers_router)
app.include_router(_vault_router)
app.include_router(_webhook_router)
app.include_router(_terminal_router)
app.include_router(_browser_router)
app.include_router(_coding_router)
app.include_router(_web_router)
app.include_router(_vision_router)
app.include_router(_gemini_vision_router) # P48: /api/vision/gemini + /api/vision/screenshot_analyze
app.include_router(_email_router)
app.include_router(_db_router)
app.include_router(_research_router)
app.include_router(_deploy_router)
app.include_router(_scheduler_router) # GAP-2.1
app.include_router(_benchmark_router) # S-BENCH: /api/debug/benchmark
app.include_router(_tg_webhook_router) # TG-BOT: /api/telegram/webhook + /api/telegram/config/invalidate
app.include_router(_telemetry_router) # BG-3: /api/telemetry
app.include_router(_agent_telemetry_router) # Gap N4: /api/agent-telemetry/sync
app.include_router(_logs_router) # Gap 2.2: /api/logs + /api/logs/frontend
app.include_router(_incident_router) # GAP-A1: Incident Registry
app.include_router(_decision_router) # GAP-A2: Decision Memory
app.include_router(_cache_router) # Gap 2.3: /api/cache/stats
app.include_router(_daemon_status_router) # DAEMON-STATUS: /api/daemon/status
app.include_router(_blackboard_router) # S-BB: /api/blackboard/**
app.include_router(_state_sync_router) # S901: /api/sync/**
app.include_router(_jq_router) # S-DUAL-2: /api/jq/**
app.include_router(_integrity_router) # P41: /api/integrity/**
if _skill_tracker_router is not None:
app.include_router(_skill_tracker_router) # P17-B2: /api/agent/skill-record + /api/agent/skill-stats (DELETE)
app.include_router(_mcp_router) # P19-B3: /api/mcp — MCP JSON-RPC 2.0
app.include_router(_ads_router) # ADS-MANAGER: /api/ads/**
if _has_sem_mem:
app.include_router(_sem_mem_router) # S766-MEM: /api/semantic-memory/**
# (memory/sync router montato in _on_startup)
# ── Startup: heartbeat + warmup ────────────────────────────────────────────────
import asyncio as _asyncio_main
def _log_task_exc(task: '_asyncio_main.Task[object]', name: str = '') -> None:
"""Done callback — loga eccezioni non gestite nei background task (P2)."""
try:
if not task.cancelled():
exc = task.exception()
if exc:
_logger.error('BG task %r crashed: %s', name or task.get_name(), exc, exc_info=exc)
except Exception:
pass
@app.on_event('startup')
async def _on_startup():
from api.providers import start_heartbeat
start_heartbeat()
_logger.info('BOOT: heartbeat started')
# Master-Plan x5 — Fase 2.1: SPACE_ROLE gate
# Solo SPACE_ROLE=brain (Space A arjanit98-terminal) avvia lo scheduler.
# hands/memory saltano per evitare duplicati.
_space_role = os.getenv('SPACE_ROLE', 'unknown')
if _space_role in ('brain', 'unknown'):
_start_scheduler()
_logger.info('BOOT: scheduler avviato (SPACE_ROLE=%s)', _space_role)
else:
_logger.info('BOOT: scheduler SKIP — riservato a SPACE_ROLE=brain (nodo=%s)', _space_role)
_start_incident_reg() # GAP-A1: Incident Registry
_start_decision_mem() # GAP-A2: Decision Memory
_logger.info('BOOT: core services (incident/decision) avviati')
# S388: warmup TCP connection pools — inizializza i client Groq con 1 token
# così la prima vera richiesta utente non paga il costo di handshake HTTP/TLS (~80ms per provider).
# GAP-5-FIX: factory montata qui, DOPO _get_mem_manager_async() che garantisce
# MemoryManager.init() completato prima che le richieste arrivino.
try:
from memory.sync import create_memory_sync_router as _create_sync_router
from api.state import _get_mem_manager_async as _gmm_async
_mem = await _gmm_async()
if _mem is not None:
_sync_router = _create_sync_router(_mem)
app.include_router(_sync_router)
_logger.info('BOOT: memory/sync router OK')
else:
_logger.info('BOOT: memory/sync router skip (manager None)')
except Exception as _sync_err:
_logger.warning('BOOT: memory/sync err — %s', _sync_err)
import asyncio as _aio
_t_wm = _aio.create_task(_startup_warmup())
_t_wm.add_done_callback(lambda t: _log_task_exc(t, 'startup_warmup'))
# GAP-STATE: ripristina _agent_tasks da Supabase snapshot + avvia bg persist
try:
from api.state import restore_agent_tasks_from_snap as _restore_snap, persist_state_snapshot as _snap_bg
await _restore_snap()
# GAP-2: crash-recovery — task zombi RUNNING vengono resettati a pending
try:
from api.state import _agent_tasks as _agt_cr
_requeued = 0
for _tid_cr, _td_cr in list(_agt_cr.items()):
if _td_cr.get('_snap_restored') and _td_cr.get('status') in ('running', 'RUNNING'):
_td_cr['status'] = 'pending'
_td_cr['_crash_recovered'] = True
_requeued += 1
if _requeued:
_logger.info('BOOT GAP-2: %d task crash-recovered → status reset a pending', _requeued)
except Exception as _cr_err:
_logger.warning('BOOT GAP-2: crash-recover skip — %s', _cr_err)
_t_snap = _aio.create_task(_snap_bg())
_t_snap.add_done_callback(lambda t: _log_task_exc(t, 'snap_bg'))
_logger.info('BOOT: GAP-STATE snapshot bg avviato')
except Exception as _gstate_err:
_logger.warning('BOOT: GAP-STATE skip — %s', _gstate_err)
# NOTA: heartbeat Telegram rimosso — notifiche gestite dal daemon Node.js
# GAP-NEW-5: telemetry alert loop — campiona ogni 5min, alert Telegram su soglie
try:
from api.telemetry import telemetry_alert_loop as _tel_alert
_t_tel = _aio.create_task(_tel_alert())
_t_tel.add_done_callback(lambda t: _log_task_exc(t, 'telemetry_alert_loop'))
_logger.info('BOOT: telemetry alert loop avviato')
except Exception as _tel_err:
_logger.warning('BOOT: telemetry alert skip — %s', _tel_err)
# S-DUAL-2: job queue consumer + load publisher
try:
from api.job_queue import start_job_queue_consumer as _start_jq
_t_jq = _aio.create_task(_start_jq())
_t_jq.add_done_callback(lambda t: _log_task_exc(t, 'job_queue_consumer'))
_logger.info('BOOT: job queue consumer/publisher avviato (SPACE_ROLE=%s)', os.getenv('SPACE_ROLE', 'unknown'))
except Exception as _jq_err:
_logger.warning('BOOT: job queue consumer skip — %s', _jq_err)
# S766-GRID-5: HeartbeatMonitor daemon D↔B — solo su SPACE_ROLE=brain/unknown
if _space_role in ('brain', 'unknown'):
try:
from api.self_healing import get_heartbeat_monitor as _get_hbm
_hbm = _get_hbm()
await _hbm.start()
_logger.info('BOOT: HeartbeatMonitor avviato — daemon D/B sotto controllo (S766-GRID-5)')
except Exception as _hbm_err:
_logger.warning('BOOT: HeartbeatMonitor skip — %s', _hbm_err)
async def _startup_warmup() -> None:
"""
S388: Warmup dei provider Groq al boot.
Spara 1 token a ogni slot Groq in parallelo — preinizializza i connection pool HTTP.
Non blocca il boot, fallback silenzioso su qualsiasi errore.
Attende 1s per permettere a FastAPI di completare il setup.
"""
import asyncio as _aio
await _aio.sleep(1)
try:
from api.state import _get_ai_client
client = _get_ai_client()
if not client or not client.providers:
return
# Warma solo gli slot Groq (veloci, <500ms) — non Gemini/OpenRouter
groq_providers = [p for p in client.providers if p.name.startswith("groq")]
if not groq_providers:
return
async def _warm_one(provider) -> None:
try:
c = client._client_for(provider)
await _aio.wait_for(
_aio.to_thread(
c.chat.completions.create,
model=provider.default_model,
messages=[{"role": "user", "content": "hi"}],
max_tokens=1,
stream=False,
),
timeout=5.0,
)
_logger.info('BOOT: warmup OK — %s (%s)', provider.name, provider.default_model.split('/')[-1][:24])
except Exception as exc:
_logger.warning('BOOT: warmup skip — %s: %s', provider.name, str(exc)[:60])
await _aio.gather(*[_warm_one(p) for p in groq_providers])
except Exception as exc:
_logger.warning('BOOT: warmup failed: %s', exc)
# P17-B4: pip pre-warm — importa i 20 moduli più usati dagli script sandbox
# così la prima exec utente non paga il costo di import (~30-200ms/modulo).
# Silenzioso: se non installato, skip.
import importlib
_PIP_PREWARM = [
"numpy", "pandas", "matplotlib", "requests", "httpx",
"json", "re", "os", "sys", "math",
"datetime", "pathlib", "itertools", "functools", "collections",
"typing", "dataclasses", "io", "base64", "hashlib",
]
for _pkg in _PIP_PREWARM:
try:
importlib.import_module(_pkg)
except Exception:
pass
_logger.info("BOOT: pip pre-warm %d modules done", len(_PIP_PREWARM))
# ── Frontend static (SPA) ──────────────────────────────────��───────────────────
_STATIC_DIR = os.getenv('FRONTEND_DIST', '/app/backend/static')
if os.path.isdir(_STATIC_DIR):
app.mount('/', StaticFiles(directory=_STATIC_DIR, html=True), name='spa')
_logger.info('BOOT: serving frontend from %s', _STATIC_DIR)
else:
_logger.warning('BOOT: no frontend at %s', _STATIC_DIR)
_logger.info('BOOT: main.py v%s ready — %s routes registered ✓', app.version, len(app.routes))