Spaces:
Running
Running
File size: 29,732 Bytes
28a08e7 8835ca1 28a08e7 23f73d8 28a08e7 8835ca1 28a08e7 8835ca1 28a08e7 23f73d8 28a08e7 23f73d8 28a08e7 6205a50 28a08e7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 | """
backend/api/scheduler.py β Scheduler server-side persistente.
Sostituisce il loop client-side browser con un ciclo asyncio sul backend.
I task sopravvivono alla chiusura del browser iPhone.
Architettura:
- asyncio background task (tick ogni 60s)
- JSON file per persistenza (sopravvive al processo, si resetta al restart HF Space)
- Frontend re-sincronizza Dexie β backend al mount (POST /api/scheduler/sync)
- SSE push in real-time (<100ms) invece di polling 30s
- Timeout task: derivato da Policy Engine per risk level (safe=30s, medium=90s, risky=180s, dangerous=300s)
- Un solo task per tick (same invariant del client-side)
Route:
GET /api/scheduler/tasks lista tutti i task
POST /api/scheduler/tasks crea task dal frontend
PATCH /api/scheduler/tasks/{id} pausa / riprendi / aggiorna
DELETE /api/scheduler/tasks/{id} cancella task
POST /api/scheduler/sync bulk upsert da Dexie (idempotente)
POST /api/scheduler/trigger/{id} esecuzione immediata (debug/manuale)
GET /api/scheduler/status stato del loop asyncio
GET /api/scheduler/webhook/sse SSE stream real-time per frontend
"""
import asyncio
import datetime
import json
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from .state import safe_json_dumps
from .task_tool_policy import build_task_tool_policy
import os
import time
import uuid
from pathlib import Path
from typing import Any, AsyncGenerator, Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from .auth_guard import require_role, AuthRole
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
import logging
_logger = logging.getLogger("agente_ai") # S-BUGFIX
logger = logging.getLogger("agente_ai.scheduler")
def _log_task_exc(task): # GAP-2.6: log silently-dropped exceptions in fire-and-forget tasks
if not task.cancelled():
exc = task.exception()
if exc:
logger.warning("[scheduler] background task raised %s: %s", type(exc).__name__, exc)
router = APIRouter(prefix="/api/scheduler", tags=["scheduler"], dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: tutti endpoint scheduler ora fail-closed
# βββ Telegram notifications (fire-and-forget) βββββββββββββββββββββββββββββββββ
# Definisci sempre le funzioni dummy PRIMA di qualsiasi import tentativo.
async def _tg_done(*_a, **_kw): pass # type: ignore[misc]
async def _tg_error(*_a, **_kw): pass # type: ignore[misc]
async def _tg_start(*_a, **_kw): pass # type: ignore[misc]
async def _tg_heartbeat(*_a, **_kw): pass # type: ignore[misc]
try:
from .telegram_notify import (
notify_task_done as _tg_done,
notify_task_error as _tg_error,
notify_task_start as _tg_start,
notify_task_heartbeat as _tg_heartbeat,
)
except Exception as _tg_import_err:
logger.debug("[scheduler] Telegram notifications unavailable: %s", _tg_import_err)
# βββ Persistenza JSON βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Usa /tmp su HF Space (ephemeral ma dura ore).
# Il frontend re-sincronizza Dexie β backend al mount: zero task persi.
_TASKS_FILE = Path(os.getenv("SCHEDULER_TASKS_FILE", "/tmp/agente_scheduler.json"))
_TASKS_BAK = Path(str(os.getenv("SCHEDULER_TASKS_FILE", "/tmp/agente_scheduler.json")) + ".bak")
_tasks: dict[str, dict] = {} # id β task (in-memory, fonte di veritΓ )
_lock = asyncio.Lock() # serializza tutti i write (no race conditions)
# DEAD-LETTER-WATCHDOG: task rimasti "running" oltre questo timeout vengono
# resettati a "pending" dal tick β previene blocco permanente del loop.
# βββ Policy Engine integration (ARCH-I4.6) ββββββββββββββββββββββββββββββββββββ
# Fail-open: se policy non disponibile β fallback ai valori originali hardcoded.
try:
from .policy import (
RISK_TIMEOUT_S as _POLICY_TIMEOUT_S,
RISK_MAX_RETRY as _POLICY_MAX_RETRY,
_quota_check as _policy_quota_check,
_quota_consume as _policy_quota_consume,
)
_POLICY_AVAILABLE = True
except Exception as _policy_import_err: # pragma: no cover
logger.warning("[scheduler] Policy Engine non disponibile β fallback hardcoded: %s", _policy_import_err)
_POLICY_TIMEOUT_S = {"safe": 30, "medium": 120, "risky": 180, "dangerous": 300}
_POLICY_MAX_RETRY = {"safe": 3, "medium": 2, "risky": 1, "dangerous": 0}
def _policy_quota_check(sid, tool, risk): # type: ignore[misc]
return True, 99
def _policy_quota_consume(sid, tool): # type: ignore[misc]
pass
_POLICY_AVAILABLE = False
_VALID_RISK = frozenset(_POLICY_TIMEOUT_S)
_DEFAULT_RISK = "medium"
# DEAD-LETTER-WATCHDOG: margine sopra il timeout massimo del livello dangerous.
_STUCK_TIMEOUT_S = max(_POLICY_TIMEOUT_S.values()) + 120 # 300 + 120 = 420s
def _load_tasks() -> None:
"""Gap-7-FIX: carica da file principale, fallback a backup se corrotto."""
global _tasks
for _path in (_TASKS_FILE, _TASKS_BAK):
try:
if _path.exists():
raw = json.loads(_path.read_text(encoding="utf-8"))
_tasks = {t["id"]: t for t in raw if isinstance(t, dict) and "id" in t}
logger.info("Scheduler: caricati %d task da %s", len(_tasks), _path)
return
except Exception as exc:
logger.warning("Scheduler: load da %s fallito (%s) β provo backup", _path, exc)
_tasks = {}
logger.warning("Scheduler: nessun task salvato trovato β partenza vuota")
def _save_tasks_sync() -> None:
"""Gap-7-FIX: write atomico (tmpβrename) + backup β resistente a crash mid-write."""
try:
_TASKS_FILE.parent.mkdir(parents=True, exist_ok=True)
data = safe_json_dumps(list(_tasks.values()), indent=2)
# Write atomico: scrive su .tmp poi rinomina β evita file corrotto se process killed
_tmp = _TASKS_FILE.with_suffix(".tmp")
_tmp.write_text(data, encoding="utf-8")
_tmp.replace(_TASKS_FILE)
# Backup separato β fallback se il file principale si corrompe al prossimo avvio
try:
_TASKS_BAK.write_text(data, encoding="utf-8")
except Exception:
pass # backup non critico β non bloccare il path principale
except Exception as exc:
logger.warning("Scheduler: save fallito: %s", exc)
# βββ SSE fan-out ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Una Queue per ogni client SSE connesso.
# _broadcast_sse() serializza i task correnti e li invia a tutti.
_sse_clients: list[asyncio.Queue] = []
def _broadcast_sse() -> None:
"""
Invia la lista task aggiornata a tutti i client SSE connessi.
Fire-and-forget: chiamato dopo ogni mutazione (create/patch/delete/execute).
Deve essere chiamato con _lock giΓ acquisito (legge _tasks direttamente).
"""
if not _sse_clients:
return
payload = safe_json_dumps(list(_tasks.values()))
event = f"event: tasks_updated\ndata: {payload}\n\n"
for q in _sse_clients:
try:
q.put_nowait(event)
except asyncio.QueueFull:
pass # client lento β skip questo evento, riceverΓ il prossimo
async def _sse_generator(queue: asyncio.Queue, request: Request) -> AsyncGenerator[str, None]:
"""
Genera eventi SSE per un client connesso.
Si chiude quando il client disconnette (request.is_disconnected()).
Heartbeat ogni 25s per mantenere la connessione viva su iOS/proxy.
"""
# Invia subito la lista task corrente (snapshot iniziale)
async with _lock:
snapshot = safe_json_dumps(list(_tasks.values()))
yield f"event: tasks_updated\ndata: {snapshot}\n\n"
while True:
if await request.is_disconnected():
break
try:
# Attendi evento o heartbeat dopo 25s
event = await asyncio.wait_for(queue.get(), timeout=25.0)
yield event
except asyncio.TimeoutError:
# Heartbeat β mantiene viva la connessione su iOS Safari / Cloudflare
yield ": heartbeat\n\n"
# βββ Helpers trigger ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _is_due(task: dict, now_ms: int) -> bool:
t = task.get("trigger", {})
tt = t.get("type")
if tt == "once": return now_ms >= t.get("runAt", 0)
if tt == "interval": return now_ms >= t.get("nextRun", 0)
if tt == "daily": return now_ms >= t.get("nextRun", 0)
if tt == "on_open": return True # boot-time task
if tt == "issue_poll": return now_ms >= t.get("nextRun", 0)
return False
def _daily_timezone(trigger: dict) -> ZoneInfo | None:
"""Ritorna il fuso IANA salvato dal browser, se disponibile e valido.
I task daily creati prima dell'introduzione del campo ``timeZone`` restano
compatibili: l'assenza o un valore non valido mantiene il calcolo nel fuso
locale del server invece di bloccare la pianificazione.
"""
time_zone = trigger.get("timeZone")
if not isinstance(time_zone, str) or not time_zone:
return None
try:
return ZoneInfo(time_zone)
except ZoneInfoNotFoundError:
logger.warning("Scheduler: timezone daily non valida (%r), fallback server-local", time_zone)
return None
def _advance_trigger(trigger: dict, now_ms: int) -> dict:
t = dict(trigger)
tt = t.get("type")
if tt in ("interval", "issue_poll"):
t["nextRun"] = now_ms + t.get("intervalMs", 3_600_000)
elif tt == "daily":
hour = t.get("hour", 9)
minute = t.get("minute", 0)
time_zone = _daily_timezone(t)
# Usa il timestamp dell'esecuzione, non l'orologio nel momento in cui
# il task termina: preserva la semantica esistente anche per task lunghi.
now = datetime.datetime.fromtimestamp(
now_ms / 1000,
tz=time_zone,
) if time_zone else datetime.datetime.fromtimestamp(now_ms / 1000)
nxt = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if nxt <= now:
nxt = nxt + datetime.timedelta(days=1)
t["nextRun"] = int(nxt.timestamp() * 1000)
# once / on_open: nessun avanzamento
return t
# βββ Esecutore task βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
async def _run_goal(goal: str, conversation_id: Optional[str] = None, risk: str = "medium") -> str:
"""
Esegue il goal tramite UnifiedAgentLoop (stesso path di api/agent.py).
Timeout: derivato da Policy Engine per risk level (safe=30s, medium=90s, risky=180s, dangerous=300s).
"""
_task_policy = build_task_tool_policy(goal)
if _task_policy.literal_response:
return _task_policy.literal_response
try:
from agents.unified_loop import UnifiedAgentLoop
from api.state import (
_get_ai_client, _get_mem_manager_async,
_get_executor, _get_planner,
)
client = _get_ai_client()
memory = await _get_mem_manager_async()
executor = _get_executor()
planner = _get_planner()
try:
from agents.critic import Critic
from agents.response_verifier import ResponseVerifier
critic = Critic(llm_client=client)
verifier = ResponseVerifier()
except Exception:
critic = None
verifier = None
loop = UnifiedAgentLoop(
llm_client=client, critic=critic, verifier=verifier,
memory=memory, executor=executor, planner=planner,
)
_timeout_s = float(_POLICY_TIMEOUT_S.get(risk, 120))
result = await asyncio.wait_for(
loop.run(goal=goal, context="", max_steps=8,
allow_tools=not _task_policy.forbid_tools),
timeout=_timeout_s,
)
if isinstance(result, dict):
# Preserve structured loop outcomes; never turn controlled failures into empty strings.
output = next((result.get(key) for key in ("output", "answer", "explanation", "error")
if result.get(key)), "")
else:
output = str(result)
return str(output)[:1000]
except asyncio.TimeoutError:
return f"β Timeout: task terminato dopo {int(_POLICY_TIMEOUT_S.get(risk, 120))}s"
except Exception as exc:
logger.error("Scheduler._run_goal error: %s", exc, exc_info=True)
return f"β Errore: {str(exc)[:400]}"
async def _sb_write_scheduler_result(task_id: str, goal: str, status: str, result: str, now_ms: int) -> None:
"""Scrive risultato scheduler su Supabase (fire-and-forget). Sopravvive al riavvio HF Space."""
try:
from .persistence import sb_upsert_task, sb_append_event
import json as _json
await sb_upsert_task(task_id, goal[:500], status, 0, [], now_ms)
_evt = safe_json_dumps({"type": "task_done", "result": result[:800], "source": "scheduler", "taskId": task_id})
await sb_append_event(task_id, 0, f"data: {_evt}\n\n")
except Exception as _exc:
logger.warning("Scheduler: sb_write_result failed: %s", _exc)
async def _execute_task(task_id: str) -> None:
"""Esegue un task, aggiorna status e salva."""
now_ms = int(time.time() * 1000)
# Marca running + broadcast SSE
async with _lock:
task = _tasks.get(task_id)
if not task:
return
task["status"] = "running"
task["lastRunAt"] = now_ms
_task_notify = task.get("notify", True)
_task_label = task.get("label", task.get("goal", ""))[:200]
_task_goal = task.get("goal", _task_label)[:200]
_task_risk = task.get("risk", _DEFAULT_RISK)
if _task_risk not in _VALID_RISK:
_task_risk = _DEFAULT_RISK
_save_tasks_sync()
_broadcast_sse()
# Policy Engine (ARCH-I4.6): quota check fail-open β non blocca, solo log warning.
_quota_ok, _quota_rem = _policy_quota_check("scheduler", "scheduled_task", _task_risk)
if not _quota_ok:
logger.warning(
"[scheduler] quota esaurita (risk=%s) per task %s β eseguo comunque (fail-open)",
_task_risk, task_id,
)
else:
_policy_quota_consume("scheduler", "scheduled_task")
if _task_notify:
asyncio.create_task(_tg_start(task_id, _task_goal)).add_done_callback(_log_task_exc)
try:
result = await _run_goal(task["goal"], task.get("conversationId"), risk=_task_risk)
async with _lock:
task = _tasks.get(task_id)
if not task:
return
ttype = task["trigger"].get("type")
one_shot = ttype in ("once", "on_open")
task["status"] = "done" if one_shot else "pending"
task["trigger"] = _advance_trigger(task["trigger"], now_ms)
task["lastRunAt"] = now_ms
task["lastResult"] = result
task["errorCount"] = 0
_save_tasks_sync()
_broadcast_sse()
_sb_goal_ok = task.get("goal", task.get("label", ""))[:500]
_sb_stat_ok = "done" if one_shot else "pending"
logger.info("Scheduler: β task '%s' (%s)", task.get("label"), task_id)
asyncio.create_task(_sb_write_scheduler_result(task_id, _sb_goal_ok, _sb_stat_ok, result, now_ms)).add_done_callback(_log_task_exc)
if _task_notify:
asyncio.create_task(_tg_done(task_id, _task_goal, result[:500])).add_done_callback(_log_task_exc)
except Exception as exc:
async with _lock:
task = _tasks.get(task_id)
if not task:
return
task["errorCount"] = task.get("errorCount", 0) + 1
failed = task["errorCount"] >= task.get("maxErrors", _POLICY_MAX_RETRY.get(_task_risk, 2))
task["status"] = "failed" if failed else "pending"
if not failed:
task["trigger"] = _advance_trigger(
task["trigger"], now_ms + 5 * 60_000
)
task["lastRunAt"] = now_ms
task["lastResult"] = f"β {str(exc)[:300]}"
_save_tasks_sync()
_broadcast_sse()
logger.error("Scheduler: β task %s: %s", task_id, exc)
# GAP-A1: log incident in registry (fire-and-forget, non-blocking)
try:
from .incident_registry import log_incident as _log_inc
asyncio.create_task(_log_inc(
task_id=task_id, goal=_task_goal, error=str(exc), source="scheduler"
)).add_done_callback(_log_task_exc)
except Exception as _exc:
_logger.debug("[scheduler] silenced %s", type(_exc).__name__) # noqa: BLE001
_sb_goal_err = task.get("goal", task.get("label", ""))[:500] if task else ""
_sb_stat_err = "failed" if failed else "pending"
asyncio.create_task(_sb_write_scheduler_result(task_id, _sb_goal_err, _sb_stat_err, f"β {str(exc)[:300]}", now_ms)).add_done_callback(_log_task_exc)
if _task_notify:
asyncio.create_task(_tg_error(task_id, _task_goal, str(exc)[:300])).add_done_callback(_log_task_exc)
# βββ Background loop ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
_loop_task: Optional[asyncio.Task] = None
_current_running: Optional[str] = None # task_id in esecuzione
async def _tick() -> None:
"""Controlla task scaduti; lancia il primo trovato (uno per tick)."""
global _current_running
# DEAD-LETTER-WATCHDOG: se il task corrente Γ¨ "running" da troppo tempo,
# Γ¨ probabilmente bloccato (crash silenzioso, HF Space OOM, ecc.).
# Reset a "pending" + notifica Telegram + incrementa errorCount.
if _current_running:
async with _lock:
t = _tasks.get(_current_running, {})
if t.get("status") == "running":
last_run = t.get("lastRunAt") or 0
elapsed_s = (time.time() * 1000 - last_run) / 1000
if elapsed_s > _STUCK_TIMEOUT_S:
# Task bloccato β reset e notifica
elapsed_min = elapsed_s / 60
goal = t.get("goal", t.get("label", ""))[:200]
logger.warning(
"Scheduler DEAD-LETTER: task '%s' bloccato da %.1fmin β reset a pending",
_current_running, elapsed_min,
)
asyncio.create_task(_tg_heartbeat(
_current_running, goal, elapsed_min,
current_step="timeout watchdog",
force=True,
)).add_done_callback(_log_task_exc)
async with _lock:
t["status"] = "pending"
t["lastResult"] = f"β οΈ Reset automatico: bloccato da {elapsed_min:.0f}min."
t["errorCount"] = t.get("errorCount", 0) + 1
_save_tasks_sync()
_broadcast_sse()
_current_running = None
else:
return # ancora in esecuzione, entro timeout
else:
_current_running = None
now_ms = int(time.time() * 1000)
async with _lock:
pending = [t for t in _tasks.values() if t.get("status") == "pending"]
due = [t for t in pending if _is_due(t, now_ms)]
if not due:
return
task = due[0]
_current_running = task["id"]
logger.info("Scheduler: lancio task '%s'", task.get("label"))
asyncio.create_task(_execute_task(task["id"])).add_done_callback(_log_task_exc)
async def _scheduler_loop() -> None:
"""Loop principale: avvio ritardato di 5s poi tick ogni 60s."""
await asyncio.sleep(5) # non bloccare FastAPI startup
logger.info("Scheduler: loop asyncio avviato β")
while True:
try:
await _tick()
except Exception as exc:
logger.error("Scheduler: tick error: %s", exc)
await asyncio.sleep(60)
def start_scheduler() -> None:
"""
Avvia il loop scheduler. Chiamato in _on_startup() di main.py.
Idempotente β sicuro su multipli import.
"""
global _loop_task
_load_tasks()
# Crash recovery: task rimasti 'running' dopo un restart
for t in _tasks.values():
if t.get("status") == "running":
t["status"] = "pending"
t["lastResult"] = "β οΈ Ripristinato dopo riavvio backend."
_save_tasks_sync()
if _loop_task is None or _loop_task.done():
_loop_task = asyncio.create_task(_scheduler_loop())
_loop_task.add_done_callback(_log_task_exc) # GAP-2.6: log silently-dropped exceptions
logger.info("Scheduler: asyncio task creato β")
# βββ Pydantic models ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
class TaskCreate(BaseModel):
id: Optional[str] = None
label: str
goal: str
trigger: dict
notify: bool = True
maxErrors: int = 3
conversationId: Optional[str] = None
risk: str = "medium" # ARCH-I4.6: safe | medium | risky | dangerous
class TaskPatch(BaseModel):
status: Optional[str] = None # "pending" | "paused"
trigger: Optional[dict] = None
label: Optional[str] = None
# βββ REST Endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@router.get("/tasks")
async def list_tasks() -> list[dict]:
"""Polling dal frontend (fallback se SSE non disponibile) β fonte di veritΓ server-side."""
async with _lock:
return list(_tasks.values())
@router.post("/tasks", status_code=201)
async def create_task(body: TaskCreate) -> dict:
"""Crea task sul backend. Il frontend chiama questo DOPO il salvataggio Dexie."""
tid = body.id or f"sched_{int(time.time()*1000):x}_{uuid.uuid4().hex[:4]}"
task: dict[str, Any] = {
"id": tid,
"label": body.label,
"goal": body.goal,
"trigger": body.trigger,
"status": "pending",
"createdAt": int(time.time() * 1000),
"lastRunAt": None,
"lastResult": None,
"errorCount": 0,
"maxErrors": body.maxErrors,
"notify": body.notify,
"conversationId": body.conversationId,
"risk": body.risk if body.risk in _VALID_RISK else _DEFAULT_RISK,
}
async with _lock:
_tasks[tid] = task
_save_tasks_sync()
_broadcast_sse()
logger.info("Scheduler: task creato '%s' (%s)", body.label, tid)
return task
@router.patch("/tasks/{task_id}")
async def patch_task(task_id: str, body: TaskPatch) -> dict:
"""Pausa, riprendi, o aggiorna label/trigger di un task."""
async with _lock:
task = _tasks.get(task_id)
if not task:
raise HTTPException(404, "Task non trovato")
if body.status is not None: task["status"] = body.status
if body.trigger is not None: task["trigger"] = body.trigger
if body.label is not None: task["label"] = body.label
_save_tasks_sync()
_broadcast_sse()
return task
@router.delete("/tasks/{task_id}", status_code=204)
async def delete_task(task_id: str) -> None:
"""Cancella task dal backend."""
async with _lock:
if task_id not in _tasks:
raise HTTPException(404, "Task non trovato")
del _tasks[task_id]
_save_tasks_sync()
_broadcast_sse()
@router.post("/sync")
async def sync_tasks(body: list[dict]) -> dict:
"""
Bulk upsert da Dexie β backend.
Idempotente: inserisce solo i task assenti. Non sovrascrive quelli esistenti.
Chiamato dal frontend al mount β porta i task locali iPhone nel backend.
"""
added = 0
async with _lock:
for t in body:
tid = t.get("id")
if (
tid
and tid not in _tasks
and t.get("status") in ("pending", "paused")
and isinstance(t.get("trigger"), dict)
):
_tasks[tid] = {
**t,
"lastResult": (t.get("lastResult") or "")
+ (" [synced da client]" if t.get("lastResult") else "[synced da client]"),
}
added += 1
if added:
_save_tasks_sync()
_broadcast_sse()
logger.info("Scheduler: sync β %d task aggiunti (totale: %d)", added, len(_tasks))
return {"synced": added, "total": len(_tasks)}
@router.post("/trigger/{task_id}")
async def trigger_task_now(task_id: str) -> dict:
"""Esecuzione immediata ignorando il trigger temporale (debug / run manuale)."""
async with _lock:
task = _tasks.get(task_id)
if not task:
raise HTTPException(404, "Task non trovato")
asyncio.create_task(_execute_task(task_id)).add_done_callback(_log_task_exc)
return {"triggered": task_id, "label": task.get("label")}
@router.get("/status")
async def scheduler_status() -> dict:
"""Stato del loop asyncio β usato dal frontend per il badge βοΈ/π±."""
loop_ok = _loop_task is not None and not _loop_task.done()
async with _lock:
total = len(_tasks)
pending = sum(1 for t in _tasks.values() if t.get("status") == "pending")
running = sum(1 for t in _tasks.values() if t.get("status") == "running")
return {
"loopRunning": loop_ok,
"currentTask": _current_running,
"totalTasks": total,
"pendingTasks": pending,
"runningTasks": running,
"sseClients": len(_sse_clients),
}
@router.get("/delta")
async def scheduler_delta(since_ms: int = 0) -> dict:
"""GAP-A7: Delta-only view β solo task aggiornati dopo since_ms (epoch ms).
Permette polling incrementale efficiente dal frontend:
invece di ricevere tutti i task ad ogni tick, il client riceve solo
quelli effettivamente cambiati dall'ultima lettura.
Utilizzo:
GET /api/scheduler/delta?since_ms=0 β tutti i task (full sync iniziale)
GET /api/scheduler/delta?since_ms=1718000000000 β solo task aggiornati dopo ts
Risposta:
{ "tasks": [...], "count": N, "since_ms": M, "now_ms": T, "total": TOT }
Campo di riferimento per il filtro: updated_at (ms epoch).
Se updated_at non presente sul task, usa createdAt come fallback.
"""
import time as _t
now_ms = int(_t.time() * 1000)
async with _lock:
if since_ms == 0:
delta = list(_tasks.values())
else:
delta = [
t for t in _tasks.values()
if max(
t.get("updated_at", 0),
t.get("updatedAt", 0),
t.get("createdAt", 0),
t.get("created_at", 0),
) > since_ms
]
return {
"tasks": delta,
"count": len(delta),
"since_ms": since_ms,
"now_ms": now_ms,
"total": len(_tasks),
}
@router.get("/webhook/sse")
async def sse_stream(request: Request) -> StreamingResponse:
"""
SSE endpoint β il frontend si connette qui per ricevere aggiornamenti real-time.
Protocollo:
- event: tasks_updated β data: JSON array di tutti i task correnti
- : heartbeat β commento SSE (mantiene viva la connessione su iOS/proxy)
Il client riceve uno snapshot immediato alla connessione, poi push ad ogni mutazione.
Riconnessione automatica gestita dal browser (EventSource ha retry built-in).
"""
queue: asyncio.Queue = asyncio.Queue(maxsize=16)
_sse_clients.append(queue)
logger.info("Scheduler SSE: client connesso (totale: %d)", len(_sse_clients))
async def cleanup_generator() -> AsyncGenerator[str, None]:
try:
async for event in _sse_generator(queue, request):
yield event
finally:
try:
_sse_clients.remove(queue)
except ValueError as _exc:
_logger.debug("[scheduler] silenced %s", type(_exc).__name__) # noqa: BLE001
logger.info("Scheduler SSE: client disconnesso (totale: %d)", len(_sse_clients))
return StreamingResponse(
cleanup_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # disabilita buffering Nginx/proxy
"Connection": "keep-alive",
},
)
|