Spaces:
Running
Running
sync: 194 file da Baida98/AI@3cf07303 (2026-08-30 07:30 UTC)
#151
by Baida07 - opened
- agents/executor.py +8 -32
- agents/unified_loop.py +29 -60
- api/background_tasks.py +0 -53
- api/event_store.py +35 -32
- api/job_queue.py +11 -8
- api/providers.py +2 -7
- api/public_snapshot.py +6 -14
- api/speculative.py +16 -14
- api/state.py +1 -6
- benchmark-extended.mjs +0 -0
- cors_policy.py +0 -61
- main.py +9 -50
- scripts/lib/report-engine.mjs +36 -0
- tests/test_agent_task_pruning.py +0 -70
- tests/test_ai_provider_health_access.py +0 -83
- tests/test_background_tasks.py +0 -57
- tests/test_cors_policy.py +0 -65
- tests/test_executor_side_effect_retry.py +0 -91
- tests/test_model_watch_adapter.py +2 -2
- tests/test_public_snapshot_writer.py +9 -46
- tests/test_shell_safety_pip.py +0 -32
- tests/test_telemetry_alert_lifecycle.py +0 -45
- tests/test_vfs_atomic_rollback.py +0 -76
- tools/_shell_safety.py +2 -6
- tools/registry.py +1 -29
agents/executor.py
CHANGED
|
@@ -292,39 +292,15 @@ class Executor:
|
|
| 292 |
_t0 = _time_mod.monotonic()
|
| 293 |
result = await asyncio.wait_for(fn(**inputs), timeout=_adaptive_to)
|
| 294 |
_timeout_tracker.record(tool_name, _time_mod.monotonic() - _t0)
|
| 295 |
-
|
| 296 |
-
# Il tool ha già prodotto il side effect: la persistenza memoria
|
| 297 |
-
# è osservabilità e non deve riaprire il retry del tool.
|
| 298 |
-
_memory_persisted = True
|
| 299 |
-
_memory_error = None
|
| 300 |
if self.memory:
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
except Exception as _memory_exc:
|
| 310 |
-
_memory_persisted = False
|
| 311 |
-
_memory_error = f"{type(_memory_exc).__name__}: {str(_memory_exc)[:240]}"
|
| 312 |
-
_logger.warning(
|
| 313 |
-
"[executor] tool %s completato ma save_episode fallito; "
|
| 314 |
-
"nessun retry del side effect: %s",
|
| 315 |
-
tool_name,
|
| 316 |
-
_memory_error,
|
| 317 |
-
)
|
| 318 |
-
response = {
|
| 319 |
-
"success": True,
|
| 320 |
-
"tool": tool_name,
|
| 321 |
-
"output": result,
|
| 322 |
-
"attempt": attempt + 1,
|
| 323 |
-
"memory_persisted": _memory_persisted,
|
| 324 |
-
}
|
| 325 |
-
if _memory_error:
|
| 326 |
-
response["memory_error"] = _memory_error
|
| 327 |
-
return response
|
| 328 |
|
| 329 |
except asyncio.TimeoutError:
|
| 330 |
# FIX-GAP2: registra il timeout come durata massima per shrink futuro
|
|
|
|
| 292 |
_t0 = _time_mod.monotonic()
|
| 293 |
result = await asyncio.wait_for(fn(**inputs), timeout=_adaptive_to)
|
| 294 |
_timeout_tracker.record(tool_name, _time_mod.monotonic() - _t0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
if self.memory:
|
| 296 |
+
# S577→S600: inputs 100→500 — parity con altri handler
|
| 297 |
+
await self.memory.save_episode(
|
| 298 |
+
"tool",
|
| 299 |
+
f"{tool_name}: {str(inputs)[:500]}",
|
| 300 |
+
str(result)[:500],
|
| 301 |
+
True,
|
| 302 |
+
)
|
| 303 |
+
return {"success": True, "tool": tool_name, "output": result, "attempt": attempt + 1}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
|
| 305 |
except asyncio.TimeoutError:
|
| 306 |
# FIX-GAP2: registra il timeout come durata massima per shrink futuro
|
agents/unified_loop.py
CHANGED
|
@@ -214,46 +214,37 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 214 |
_logger.debug("[unified_loop] state callback silenced: %s", _state_callback_error)
|
| 215 |
|
| 216 |
async def _rollback_writes(self, on_step=None) -> None:
|
| 217 |
-
"""
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
|
|
|
| 221 |
"""
|
| 222 |
if not self._write_snapshots or not self.executor:
|
| 223 |
return
|
| 224 |
if on_step:
|
| 225 |
await _maybe_await(on_step({
|
| 226 |
"action": "text_chunk",
|
| 227 |
-
"token":
|
| 228 |
"status": "streaming",
|
| 229 |
}))
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
tool_name = "delete_file" if original is None else "write_file"
|
| 235 |
-
inputs = {"path": path} if original is None else {"path": path, "content": original}
|
| 236 |
try:
|
| 237 |
-
|
| 238 |
-
self.executor.run_tool(
|
| 239 |
timeout=10.0,
|
| 240 |
)
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
except Exception as exc:
|
| 248 |
-
remaining[path] = original
|
| 249 |
-
_logger.error("GAP-3 rollback fallito per %s: %s", path, str(exc)[:240])
|
| 250 |
-
|
| 251 |
-
total = len(self._write_snapshots)
|
| 252 |
-
self._write_snapshots = remaining
|
| 253 |
-
_logger.info("GAP-3 rollback: %d/%d file ripristinati", rolled, total)
|
| 254 |
-
if remaining:
|
| 255 |
-
raise RuntimeError(f"VFS rollback incompleto: {len(remaining)}/{total} file non ripristinati")
|
| 256 |
|
|
|
|
| 257 |
async def _vfs_git_backup(self) -> None:
|
| 258 |
"""GAP-NEW-4: Push _session_files al branch vfs-backup su GitHub.
|
| 259 |
|
|
@@ -1328,28 +1319,18 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 1328 |
{"path": _wf_path, "content": _wf_generated} if rn == "write_file"
|
| 1329 |
else {"path": _wf_path, "patch": _wf_generated}
|
| 1330 |
)
|
| 1331 |
-
# GAP-3: snapshot pre-write
|
| 1332 |
if rn == "write_file" and _wf_path not in self._write_snapshots:
|
| 1333 |
-
|
| 1334 |
-
|
| 1335 |
-
|
| 1336 |
-
|
| 1337 |
-
_snap_payload = _snap_r.get("output") if isinstance(_snap_r, dict) else None
|
| 1338 |
-
_snap_failed = isinstance(_snap_payload, dict) and _snap_payload.get("ok") is False
|
| 1339 |
-
if isinstance(_snap_payload, dict):
|
| 1340 |
-
_snap_content = _snap_payload.get("content")
|
| 1341 |
-
_snap_error = str(_snap_payload.get("error", ""))
|
| 1342 |
-
else:
|
| 1343 |
-
_snap_content = _snap_payload
|
| 1344 |
-
_snap_error = str(_snap_r.get("error", "")) if isinstance(_snap_r, dict) else ""
|
| 1345 |
-
if isinstance(_snap_content, str) and not _snap_failed:
|
| 1346 |
-
self._write_snapshots[_wf_path] = _snap_content
|
| 1347 |
-
elif "File non trovato" in _snap_error or "File not found" in _snap_error:
|
| 1348 |
-
self._write_snapshots[_wf_path] = None
|
| 1349 |
-
else:
|
| 1350 |
-
raise RuntimeError(
|
| 1351 |
-
f"Snapshot VFS non disponibile per {_wf_path}: {_snap_error[:240]}"
|
| 1352 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1353 |
# GAP-VFS: lock per-path — serializza scritture parallele sullo stesso file
|
| 1354 |
_vfs_lock = self._get_vfs_lock(_wf_path)
|
| 1355 |
async with _vfs_lock:
|
|
@@ -3576,12 +3557,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 3576 |
}
|
| 3577 |
|
| 3578 |
state.errors.append(error_text)
|
| 3579 |
-
if getattr(self, "_write_snapshots", None):
|
| 3580 |
-
try:
|
| 3581 |
-
await self._rollback_writes(on_step)
|
| 3582 |
-
except Exception as rollback_error:
|
| 3583 |
-
state.errors.append(str(rollback_error)[:500])
|
| 3584 |
-
_logger.error("[unified_loop] rollback inatteso incompleto: %s", rollback_error)
|
| 3585 |
previous = state.state_machine.current
|
| 3586 |
if previous != AgentState.FAILED:
|
| 3587 |
try:
|
|
@@ -3739,12 +3714,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 3739 |
|
| 3740 |
async def _finish(result: dict[str, Any]) -> dict[str, Any]:
|
| 3741 |
next_state = AgentState.COMPLETED if result.get("success", True) else AgentState.FAILED
|
| 3742 |
-
if next_state == AgentState.FAILED and getattr(self, "_write_snapshots", None):
|
| 3743 |
-
try:
|
| 3744 |
-
await self._rollback_writes(on_step)
|
| 3745 |
-
except Exception as rollback_error:
|
| 3746 |
-
result.setdefault("errors", []).append(str(rollback_error)[:500])
|
| 3747 |
-
result["rollback_incomplete"] = True
|
| 3748 |
try:
|
| 3749 |
await self._transition_state(state, next_state, on_step)
|
| 3750 |
finally:
|
|
|
|
| 214 |
_logger.debug("[unified_loop] state callback silenced: %s", _state_callback_error)
|
| 215 |
|
| 216 |
async def _rollback_writes(self, on_step=None) -> None:
|
| 217 |
+
"""
|
| 218 |
+
GAP-3: ripristina i file sovrascritti se il loop si interrompe a metà .
|
| 219 |
+
Chiama dopo un errore grave che ha lasciato il progetto in stato inconsistente.
|
| 220 |
+
Ogni file in _write_snapshots viene ripristinato al suo contenuto originale.
|
| 221 |
+
File che non esistevano (snapshot=None) vengono ignorati (non possiamo eliminarli in modo sicuro).
|
| 222 |
"""
|
| 223 |
if not self._write_snapshots or not self.executor:
|
| 224 |
return
|
| 225 |
if on_step:
|
| 226 |
await _maybe_await(on_step({
|
| 227 |
"action": "text_chunk",
|
| 228 |
+
"token": f"\u23ea Rollback di {len(self._write_snapshots)} file modificati...\n",
|
| 229 |
"status": "streaming",
|
| 230 |
}))
|
| 231 |
+
_rolled = 0
|
| 232 |
+
for path, original in self._write_snapshots.items():
|
| 233 |
+
if original is None:
|
| 234 |
+
continue # file non esisteva prima â saltiamo (non eliminiamo)
|
|
|
|
|
|
|
| 235 |
try:
|
| 236 |
+
await asyncio.wait_for(
|
| 237 |
+
self.executor.run_tool("write_file", {"path": path, "content": original}),
|
| 238 |
timeout=10.0,
|
| 239 |
)
|
| 240 |
+
_rolled += 1
|
| 241 |
+
except Exception:
|
| 242 |
+
pass # non-fatal â best effort rollback
|
| 243 |
+
_total = len(self._write_snapshots) # salva prima del clear
|
| 244 |
+
self._write_snapshots = {}
|
| 245 |
+
_logger.info("GAP-3 rollback: %d/%d file ripristinati", _rolled, _total)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
|
| 247 |
+
# ── GAP-NEW-4: Git VFS auto-snapshot ────────────────────────────────────────
|
| 248 |
async def _vfs_git_backup(self) -> None:
|
| 249 |
"""GAP-NEW-4: Push _session_files al branch vfs-backup su GitHub.
|
| 250 |
|
|
|
|
| 1319 |
{"path": _wf_path, "content": _wf_generated} if rn == "write_file"
|
| 1320 |
else {"path": _wf_path, "patch": _wf_generated}
|
| 1321 |
)
|
| 1322 |
+
# GAP-3: snapshot pre-write â cattura originale per rollback atomico
|
| 1323 |
if rn == "write_file" and _wf_path not in self._write_snapshots:
|
| 1324 |
+
try:
|
| 1325 |
+
_snap_r = await asyncio.wait_for(
|
| 1326 |
+
self.executor.run_tool("read_file", {"path": _wf_path}),
|
| 1327 |
+
timeout=4.0,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1328 |
)
|
| 1329 |
+
self._write_snapshots[_wf_path] = (
|
| 1330 |
+
_snap_r.get("output") if _snap_r.get("success") else None
|
| 1331 |
+
)
|
| 1332 |
+
except Exception:
|
| 1333 |
+
self._write_snapshots[_wf_path] = None # file non esisteva
|
| 1334 |
# GAP-VFS: lock per-path — serializza scritture parallele sullo stesso file
|
| 1335 |
_vfs_lock = self._get_vfs_lock(_wf_path)
|
| 1336 |
async with _vfs_lock:
|
|
|
|
| 3557 |
}
|
| 3558 |
|
| 3559 |
state.errors.append(error_text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3560 |
previous = state.state_machine.current
|
| 3561 |
if previous != AgentState.FAILED:
|
| 3562 |
try:
|
|
|
|
| 3714 |
|
| 3715 |
async def _finish(result: dict[str, Any]) -> dict[str, Any]:
|
| 3716 |
next_state = AgentState.COMPLETED if result.get("success", True) else AgentState.FAILED
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3717 |
try:
|
| 3718 |
await self._transition_state(state, next_state, on_step)
|
| 3719 |
finally:
|
api/background_tasks.py
DELETED
|
@@ -1,53 +0,0 @@
|
|
| 1 |
-
"""Supervision utilities for long-lived background asyncio tasks."""
|
| 2 |
-
from __future__ import annotations
|
| 3 |
-
|
| 4 |
-
import asyncio
|
| 5 |
-
import logging
|
| 6 |
-
from collections.abc import Awaitable
|
| 7 |
-
from typing import Any
|
| 8 |
-
|
| 9 |
-
_logger = logging.getLogger("agente_ai.background_tasks")
|
| 10 |
-
_tasks: dict[str, asyncio.Task[Any]] = {}
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
def spawn_background_task(coro: Awaitable[Any], *, name: str) -> asyncio.Task[Any]:
|
| 14 |
-
"""Start one named background task and retain it for lifecycle shutdown.
|
| 15 |
-
|
| 16 |
-
A live task with the same name is reused. The passed coroutine is closed in
|
| 17 |
-
that case so duplicate startup calls do not leak an un-awaited coroutine.
|
| 18 |
-
"""
|
| 19 |
-
current = _tasks.get(name)
|
| 20 |
-
if current is not None and not current.done():
|
| 21 |
-
close = getattr(coro, "close", None)
|
| 22 |
-
if close is not None:
|
| 23 |
-
close()
|
| 24 |
-
return current
|
| 25 |
-
|
| 26 |
-
task = asyncio.create_task(coro, name=name)
|
| 27 |
-
_tasks[name] = task
|
| 28 |
-
|
| 29 |
-
def _report(task_result: asyncio.Task[Any]) -> None:
|
| 30 |
-
if task_result.cancelled():
|
| 31 |
-
return
|
| 32 |
-
try:
|
| 33 |
-
error = task_result.exception()
|
| 34 |
-
except asyncio.CancelledError:
|
| 35 |
-
return
|
| 36 |
-
if error is not None:
|
| 37 |
-
_logger.error("background task %s failed: %s", name, error, exc_info=error)
|
| 38 |
-
|
| 39 |
-
task.add_done_callback(_report)
|
| 40 |
-
return task
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
async def shutdown_background_tasks() -> None:
|
| 44 |
-
"""Cancel and await all supervised background tasks."""
|
| 45 |
-
tasks = [task for task in _tasks.values() if not task.done()]
|
| 46 |
-
for task in tasks:
|
| 47 |
-
task.cancel()
|
| 48 |
-
if tasks:
|
| 49 |
-
await asyncio.gather(*tasks, return_exceptions=True)
|
| 50 |
-
_tasks.clear()
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
__all__ = ["spawn_background_task", "shutdown_background_tasks"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
api/event_store.py
CHANGED
|
@@ -4,7 +4,7 @@ backend/api/event_store.py — Event Store (persistenza, Fase 1 ADR-S26-S30)
|
|
| 4 |
Responsabilità: SALVARE tutti gli eventi per replayability, debugging, benchmark.
|
| 5 |
NON instrada — per pub/sub usa event_bus.py.
|
| 6 |
|
| 7 |
-
Schema Supabase (tabella `event_store`,
|
| 8 |
id UUID PK default gen_random_uuid()
|
| 9 |
topic TEXT NOT NULL
|
| 10 |
payload JSONB NOT NULL default '{}'
|
|
@@ -40,24 +40,25 @@ router = APIRouter(
|
|
| 40 |
|
| 41 |
_TABLE = "event_store"
|
| 42 |
|
| 43 |
-
# ──
|
| 44 |
|
| 45 |
_TABLE_CREATED = False
|
| 46 |
|
| 47 |
async def _ensure_table() -> bool:
|
| 48 |
-
"""
|
| 49 |
global _TABLE_CREATED
|
| 50 |
if _TABLE_CREATED:
|
| 51 |
return True
|
| 52 |
if not _sb:
|
| 53 |
return False
|
| 54 |
try:
|
| 55 |
-
#
|
| 56 |
res = _sb.table(_TABLE).select("id").limit(1).execute()
|
| 57 |
_TABLE_CREATED = True
|
| 58 |
return True
|
| 59 |
except Exception as exc:
|
| 60 |
-
_logger.warning("[event_store] tabella '%s' non raggiungibile: %s
|
|
|
|
| 61 |
return False
|
| 62 |
|
| 63 |
|
|
@@ -90,8 +91,9 @@ async def store_event(req: StoreEventRequest) -> StoredEvent:
|
|
| 90 |
Chiamato automaticamente dall'event_bus (via hook) o esplicitamente
|
| 91 |
dai componenti che vogliono garantire persistenza.
|
| 92 |
"""
|
| 93 |
-
|
| 94 |
-
|
|
|
|
| 95 |
|
| 96 |
record = {
|
| 97 |
"topic": req.topic,
|
|
@@ -130,8 +132,9 @@ async def replay_events(
|
|
| 130 |
Recupera eventi filtrati dall'Event Store. Supporta replay per debugging,
|
| 131 |
test di regressione e audit trail.
|
| 132 |
"""
|
| 133 |
-
|
| 134 |
-
|
|
|
|
| 135 |
|
| 136 |
try:
|
| 137 |
q = _sb.table(_TABLE).select("*").order("created_at", desc=True).limit(limit)
|
|
@@ -157,35 +160,19 @@ async def replay_events(
|
|
| 157 |
raise HTTPException(500, detail=f"Event Store query error: {exc}")
|
| 158 |
|
| 159 |
|
| 160 |
-
@router.get("/store/
|
| 161 |
-
async def
|
| 162 |
-
"""Verifica connettività dello store e restituisce statistiche."""
|
| 163 |
-
if not await _ensure_table():
|
| 164 |
-
return {"status": "unavailable", "reason": "schema event_store assente o non raggiungibile"}
|
| 165 |
-
try:
|
| 166 |
-
res = _sb.table(_TABLE).select("topic", count="exact").execute()
|
| 167 |
-
total = res.count if hasattr(res, "count") and res.count else len(res.data or [])
|
| 168 |
-
return {
|
| 169 |
-
"status": "ok",
|
| 170 |
-
"component": "event_store",
|
| 171 |
-
"total_events": total,
|
| 172 |
-
"table": _TABLE,
|
| 173 |
-
}
|
| 174 |
-
except Exception as exc:
|
| 175 |
-
return {"status": "error", "detail": str(exc)}
|
| 176 |
-
@router.get("/store/{event_id:uuid}", summary="Recupera evento singolo")
|
| 177 |
-
async def get_event(event_id: uuid.UUID) -> StoredEvent:
|
| 178 |
"""Recupera un evento specifico per ID."""
|
| 179 |
-
|
| 180 |
-
|
|
|
|
| 181 |
try:
|
| 182 |
-
|
| 183 |
-
res = _sb.table(_TABLE).select("*").eq("id", event_id_str).limit(1).execute()
|
| 184 |
if not res.data:
|
| 185 |
raise HTTPException(404, detail=f"Evento {event_id} non trovato")
|
| 186 |
row = res.data[0]
|
| 187 |
return StoredEvent(**{
|
| 188 |
-
"id": row.get("id",
|
| 189 |
"topic": row.get("topic", ""),
|
| 190 |
"payload": row.get("payload", {}),
|
| 191 |
"correlation_id": row.get("correlation_id"),
|
|
@@ -199,3 +186,19 @@ async def get_event(event_id: uuid.UUID) -> StoredEvent:
|
|
| 199 |
raise HTTPException(500, detail=f"Event Store get error: {exc}")
|
| 200 |
|
| 201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
Responsabilità: SALVARE tutti gli eventi per replayability, debugging, benchmark.
|
| 5 |
NON instrada — per pub/sub usa event_bus.py.
|
| 6 |
|
| 7 |
+
Schema Supabase (tabella `event_store`, auto-created se non esiste):
|
| 8 |
id UUID PK default gen_random_uuid()
|
| 9 |
topic TEXT NOT NULL
|
| 10 |
payload JSONB NOT NULL default '{}'
|
|
|
|
| 40 |
|
| 41 |
_TABLE = "event_store"
|
| 42 |
|
| 43 |
+
# ── Auto-create table (best-effort, richiede service role key) ─────────────────
|
| 44 |
|
| 45 |
_TABLE_CREATED = False
|
| 46 |
|
| 47 |
async def _ensure_table() -> bool:
|
| 48 |
+
"""Crea la tabella event_store su Supabase se non esiste. Best-effort."""
|
| 49 |
global _TABLE_CREATED
|
| 50 |
if _TABLE_CREATED:
|
| 51 |
return True
|
| 52 |
if not _sb:
|
| 53 |
return False
|
| 54 |
try:
|
| 55 |
+
# Prova una SELECT — se la tabella non esiste, Supabase ritorna un errore
|
| 56 |
res = _sb.table(_TABLE).select("id").limit(1).execute()
|
| 57 |
_TABLE_CREATED = True
|
| 58 |
return True
|
| 59 |
except Exception as exc:
|
| 60 |
+
_logger.warning("[event_store] tabella '%s' non raggiungibile: %s — "
|
| 61 |
+
"crea manualmente con migration Supabase", _TABLE, exc)
|
| 62 |
return False
|
| 63 |
|
| 64 |
|
|
|
|
| 91 |
Chiamato automaticamente dall'event_bus (via hook) o esplicitamente
|
| 92 |
dai componenti che vogliono garantire persistenza.
|
| 93 |
"""
|
| 94 |
+
await _ensure_table()
|
| 95 |
+
if not _sb:
|
| 96 |
+
raise HTTPException(503, detail="Event Store non disponibile (Supabase non configurato)")
|
| 97 |
|
| 98 |
record = {
|
| 99 |
"topic": req.topic,
|
|
|
|
| 132 |
Recupera eventi filtrati dall'Event Store. Supporta replay per debugging,
|
| 133 |
test di regressione e audit trail.
|
| 134 |
"""
|
| 135 |
+
await _ensure_table()
|
| 136 |
+
if not _sb:
|
| 137 |
+
raise HTTPException(503, detail="Event Store non disponibile")
|
| 138 |
|
| 139 |
try:
|
| 140 |
q = _sb.table(_TABLE).select("*").order("created_at", desc=True).limit(limit)
|
|
|
|
| 160 |
raise HTTPException(500, detail=f"Event Store query error: {exc}")
|
| 161 |
|
| 162 |
|
| 163 |
+
@router.get("/store/{event_id}", summary="Recupera evento singolo")
|
| 164 |
+
async def get_event(event_id: str) -> StoredEvent:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
"""Recupera un evento specifico per ID."""
|
| 166 |
+
await _ensure_table()
|
| 167 |
+
if not _sb:
|
| 168 |
+
raise HTTPException(503, detail="Event Store non disponibile")
|
| 169 |
try:
|
| 170 |
+
res = _sb.table(_TABLE).select("*").eq("id", event_id).limit(1).execute()
|
|
|
|
| 171 |
if not res.data:
|
| 172 |
raise HTTPException(404, detail=f"Evento {event_id} non trovato")
|
| 173 |
row = res.data[0]
|
| 174 |
return StoredEvent(**{
|
| 175 |
+
"id": row.get("id", event_id),
|
| 176 |
"topic": row.get("topic", ""),
|
| 177 |
"payload": row.get("payload", {}),
|
| 178 |
"correlation_id": row.get("correlation_id"),
|
|
|
|
| 186 |
raise HTTPException(500, detail=f"Event Store get error: {exc}")
|
| 187 |
|
| 188 |
|
| 189 |
+
@router.get("/store/status", summary="Diagnostica Event Store")
|
| 190 |
+
async def store_status():
|
| 191 |
+
"""Verifica connettività dello store e restituisce statistiche."""
|
| 192 |
+
if not _sb:
|
| 193 |
+
return {"status": "unavailable", "reason": "Supabase non configurato"}
|
| 194 |
+
try:
|
| 195 |
+
res = _sb.table(_TABLE).select("topic", count="exact").execute()
|
| 196 |
+
total = res.count if hasattr(res, "count") and res.count else len(res.data or [])
|
| 197 |
+
return {
|
| 198 |
+
"status": "ok",
|
| 199 |
+
"component": "event_store",
|
| 200 |
+
"total_events": total,
|
| 201 |
+
"table": _TABLE,
|
| 202 |
+
}
|
| 203 |
+
except Exception as exc:
|
| 204 |
+
return {"status": "error", "detail": str(exc)}
|
api/job_queue.py
CHANGED
|
@@ -355,11 +355,11 @@ async def _hands_consumer_loop() -> None:
|
|
| 355 |
continue
|
| 356 |
|
| 357 |
# Esegui in background — non blocca il loop consumer
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
)
|
| 363 |
|
| 364 |
except Exception as exc:
|
| 365 |
_logger.debug("[jq] consumer tick error: %s", exc)
|
|
@@ -377,15 +377,18 @@ async def start_job_queue_consumer() -> None:
|
|
| 377 |
return
|
| 378 |
|
| 379 |
# Load publisher su tutti gli Space
|
| 380 |
-
|
| 381 |
-
|
|
|
|
|
|
|
|
|
|
| 382 |
|
| 383 |
# Consumer per tutti i ruoli worker o legacy 'hands'
|
| 384 |
_IS_WORKER = _SPACE_ROLE.startswith("worker-") or _SPACE_ROLE in ("hands", "unknown")
|
| 385 |
|
| 386 |
if _IS_WORKER:
|
| 387 |
_logger.info("[jq] Avvio consumer loop per ruolo worker: %s", _SPACE_ROLE)
|
| 388 |
-
|
| 389 |
else:
|
| 390 |
_logger.info("[jq] SPACE_ROLE=%s — consumer non avviato (ruolo non worker)", _SPACE_ROLE)
|
| 391 |
|
|
|
|
| 355 |
continue
|
| 356 |
|
| 357 |
# Esegui in background — non blocca il loop consumer
|
| 358 |
+
t = asyncio.create_task(_execute_queued_job(job))
|
| 359 |
+
t.add_done_callback(lambda task: (
|
| 360 |
+
_logger.error("[jq] job task crashed: %s", task.exception(), exc_info=task.exception())
|
| 361 |
+
if not task.cancelled() and task.exception() else None
|
| 362 |
+
))
|
| 363 |
|
| 364 |
except Exception as exc:
|
| 365 |
_logger.debug("[jq] consumer tick error: %s", exc)
|
|
|
|
| 377 |
return
|
| 378 |
|
| 379 |
# Load publisher su tutti gli Space
|
| 380 |
+
def _log_jq_exc(t):
|
| 381 |
+
if not t.cancelled() and t.exception():
|
| 382 |
+
_logger.warning("[job_queue] bg loop raised: %s", t.exception())
|
| 383 |
+
|
| 384 |
+
asyncio.create_task(_load_publisher_loop()).add_done_callback(_log_jq_exc)
|
| 385 |
|
| 386 |
# Consumer per tutti i ruoli worker o legacy 'hands'
|
| 387 |
_IS_WORKER = _SPACE_ROLE.startswith("worker-") or _SPACE_ROLE in ("hands", "unknown")
|
| 388 |
|
| 389 |
if _IS_WORKER:
|
| 390 |
_logger.info("[jq] Avvio consumer loop per ruolo worker: %s", _SPACE_ROLE)
|
| 391 |
+
asyncio.create_task(_hands_consumer_loop()).add_done_callback(_log_jq_exc)
|
| 392 |
else:
|
| 393 |
_logger.info("[jq] SPACE_ROLE=%s — consumer non avviato (ruolo non worker)", _SPACE_ROLE)
|
| 394 |
|
api/providers.py
CHANGED
|
@@ -250,13 +250,8 @@ async def ai_provider_readiness(role: AuthRole = Depends(require_role(AuthRole.M
|
|
| 250 |
|
| 251 |
|
| 252 |
@router.get('/api/ai/health')
|
| 253 |
-
async def ai_provider_health(role: AuthRole = Depends(require_role(AuthRole.
|
| 254 |
-
"""
|
| 255 |
-
|
| 256 |
-
Il payload contiene profili, modelli, classi di errore e dati di quota upstream;
|
| 257 |
-
non deve quindi essere reso disponibile al browser tramite il token interno del
|
| 258 |
-
proxy. Il controllo pubblico di disponibilità resta `/api/health`.
|
| 259 |
-
"""
|
| 260 |
now = time.monotonic()
|
| 261 |
if _ai_health_cache["data"] and now - _ai_health_cache["at"] < _AI_HEALTH_TTL:
|
| 262 |
return _ai_health_cache["data"]
|
|
|
|
| 250 |
|
| 251 |
|
| 252 |
@router.get('/api/ai/health')
|
| 253 |
+
async def ai_provider_health(role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
|
| 254 |
+
"""Testa tutti i provider AI in parallelo — risultati cachati 60s."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
now = time.monotonic()
|
| 256 |
if _ai_health_cache["data"] and now - _ai_health_cache["at"] < _AI_HEALTH_TTL:
|
| 257 |
return _ai_health_cache["data"]
|
api/public_snapshot.py
CHANGED
|
@@ -55,14 +55,9 @@ from .version import RUNTIME_VERSION
|
|
| 55 |
|
| 56 |
_logger = logging.getLogger("agente_ai.public_snapshot")
|
| 57 |
|
| 58 |
-
_SNAPSHOT_ATTEMPTS = 3
|
| 59 |
-
_SNAPSHOT_ATTEMPT_TIMEOUT_SECONDS = 4.0
|
| 60 |
-
_SNAPSHOT_RETRY_DELAY_SECONDS = 1.0
|
| 61 |
-
_SNAPSHOT_TOTAL_BUDGET_SECONDS = (
|
| 62 |
-
_SNAPSHOT_ATTEMPTS * _SNAPSHOT_ATTEMPT_TIMEOUT_SECONDS
|
| 63 |
-
+ (_SNAPSHOT_ATTEMPTS - 1) * _SNAPSHOT_RETRY_DELAY_SECONDS
|
| 64 |
-
)
|
| 65 |
_ACTIVE_STATUSES = {"RUNNING", "IN_PROGRESS", "EXECUTING", "PROCESSING"}
|
|
|
|
|
|
|
| 66 |
def _snapshot_row() -> dict[str, Any]:
|
| 67 |
tasks = list(_agent_tasks.values())
|
| 68 |
return {
|
|
@@ -93,12 +88,9 @@ async def write_public_dashboard_snapshot() -> bool:
|
|
| 93 |
client.table("public_dashboard_snapshot").upsert(row, on_conflict="singleton").execute()
|
| 94 |
|
| 95 |
last_error: Exception | None = None
|
| 96 |
-
for attempt in range(1,
|
| 97 |
try:
|
| 98 |
-
await asyncio.
|
| 99 |
-
asyncio.to_thread(operation),
|
| 100 |
-
timeout=_SNAPSHOT_ATTEMPT_TIMEOUT_SECONDS,
|
| 101 |
-
)
|
| 102 |
_logger.info(
|
| 103 |
"BOOT: public snapshot upserted status=%s sessions=%d queued=%d in_progress=%d version=%s",
|
| 104 |
row["service_status"], row["active_sessions"], row["queued_tasks"],
|
|
@@ -107,8 +99,8 @@ async def write_public_dashboard_snapshot() -> bool:
|
|
| 107 |
return True
|
| 108 |
except Exception as exc:
|
| 109 |
last_error = exc
|
| 110 |
-
if attempt <
|
| 111 |
-
await asyncio.sleep(
|
| 112 |
|
| 113 |
error_code = getattr(last_error, "code", None) or getattr(last_error, "status_code", None)
|
| 114 |
_logger.warning(
|
|
|
|
| 55 |
|
| 56 |
_logger = logging.getLogger("agente_ai.public_snapshot")
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
_ACTIVE_STATUSES = {"RUNNING", "IN_PROGRESS", "EXECUTING", "PROCESSING"}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
def _snapshot_row() -> dict[str, Any]:
|
| 62 |
tasks = list(_agent_tasks.values())
|
| 63 |
return {
|
|
|
|
| 88 |
client.table("public_dashboard_snapshot").upsert(row, on_conflict="singleton").execute()
|
| 89 |
|
| 90 |
last_error: Exception | None = None
|
| 91 |
+
for attempt in range(1, 4):
|
| 92 |
try:
|
| 93 |
+
await asyncio.to_thread(operation)
|
|
|
|
|
|
|
|
|
|
| 94 |
_logger.info(
|
| 95 |
"BOOT: public snapshot upserted status=%s sessions=%d queued=%d in_progress=%d version=%s",
|
| 96 |
row["service_status"], row["active_sessions"], row["queued_tasks"],
|
|
|
|
| 99 |
return True
|
| 100 |
except Exception as exc:
|
| 101 |
last_error = exc
|
| 102 |
+
if attempt < 3:
|
| 103 |
+
await asyncio.sleep(2)
|
| 104 |
|
| 105 |
error_code = getattr(last_error, "code", None) or getattr(last_error, "status_code", None)
|
| 106 |
_logger.warning(
|
api/speculative.py
CHANGED
|
@@ -117,8 +117,8 @@ def _prune_cache() -> None:
|
|
| 117 |
|
| 118 |
# S388: Groq client singleton per speculative — evita new OpenAI() per ogni task.
|
| 119 |
_spec_groq_client: Any = None
|
| 120 |
-
|
| 121 |
-
|
| 122 |
def _get_spec_groq_client() -> Any:
|
| 123 |
global _spec_groq_client
|
| 124 |
if _spec_groq_client is not None:
|
|
@@ -127,8 +127,8 @@ def _get_spec_groq_client() -> Any:
|
|
| 127 |
if not groq_key:
|
| 128 |
return None
|
| 129 |
try:
|
| 130 |
-
from openai import
|
| 131 |
-
_spec_groq_client =
|
| 132 |
api_key=groq_key,
|
| 133 |
base_url="https://api.groq.com/openai/v1",
|
| 134 |
timeout=3.0,
|
|
@@ -137,6 +137,8 @@ def _get_spec_groq_client() -> Any:
|
|
| 137 |
except Exception:
|
| 138 |
_spec_groq_client = None
|
| 139 |
return _spec_groq_client
|
|
|
|
|
|
|
| 140 |
async def _extract_tools_fast(goal: str) -> list[dict]:
|
| 141 |
"""
|
| 142 |
Usa Groq openai/gpt-oss-20b per estrarre tool calls in ~300ms.
|
|
@@ -149,16 +151,16 @@ async def _extract_tools_fast(goal: str) -> list[dict]:
|
|
| 149 |
if not client:
|
| 150 |
return []
|
| 151 |
prompt = _EXTRACTION_PROMPT.format(message=goal[:500])
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
client.chat.completions.create
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
raw = (resp.choices[0].message.content or "").strip()
|
| 163 |
# Estrai JSON array anche se ci sono prefissi di testo
|
| 164 |
start = raw.find("[")
|
|
|
|
| 117 |
|
| 118 |
# S388: Groq client singleton per speculative — evita new OpenAI() per ogni task.
|
| 119 |
_spec_groq_client: Any = None
|
| 120 |
+
|
| 121 |
+
|
| 122 |
def _get_spec_groq_client() -> Any:
|
| 123 |
global _spec_groq_client
|
| 124 |
if _spec_groq_client is not None:
|
|
|
|
| 127 |
if not groq_key:
|
| 128 |
return None
|
| 129 |
try:
|
| 130 |
+
from openai import OpenAI
|
| 131 |
+
_spec_groq_client = OpenAI(
|
| 132 |
api_key=groq_key,
|
| 133 |
base_url="https://api.groq.com/openai/v1",
|
| 134 |
timeout=3.0,
|
|
|
|
| 137 |
except Exception:
|
| 138 |
_spec_groq_client = None
|
| 139 |
return _spec_groq_client
|
| 140 |
+
|
| 141 |
+
|
| 142 |
async def _extract_tools_fast(goal: str) -> list[dict]:
|
| 143 |
"""
|
| 144 |
Usa Groq openai/gpt-oss-20b per estrarre tool calls in ~300ms.
|
|
|
|
| 151 |
if not client:
|
| 152 |
return []
|
| 153 |
prompt = _EXTRACTION_PROMPT.format(message=goal[:500])
|
| 154 |
+
resp = await asyncio.wait_for(
|
| 155 |
+
asyncio.to_thread(
|
| 156 |
+
client.chat.completions.create,
|
| 157 |
+
model="openai/gpt-oss-20b",
|
| 158 |
+
messages=[{"role": "user", "content": prompt}],
|
| 159 |
+
temperature=0.0,
|
| 160 |
+
max_tokens=400, # S587: 256→400 — JSON array da goal[:500] supera 256 tok
|
| 161 |
+
),
|
| 162 |
+
timeout=3.0,
|
| 163 |
+
)
|
| 164 |
raw = (resp.choices[0].message.content or "").strip()
|
| 165 |
# Estrai JSON array anche se ci sono prefissi di testo
|
| 166 |
start = raw.find("[")
|
api/state.py
CHANGED
|
@@ -139,11 +139,6 @@ _CHECKPOINT_TTL_MS = 2 * 60 * 60 * 1000
|
|
| 139 |
_CHECKPOINT_MAX = 100
|
| 140 |
_AGENT_TASK_TTL_MS = 2 * 60 * 60 * 1000
|
| 141 |
_AGENT_TASK_MAX = 200
|
| 142 |
-
# All statuses that represent a finished task and can therefore expire from the
|
| 143 |
-
# bounded in-memory store. Keep this set aligned with API lifecycle writers.
|
| 144 |
-
_AGENT_TASK_TERMINAL_STATES: frozenset[str] = frozenset({
|
| 145 |
-
'SUCCESS', 'COMPLETED', 'ERROR', 'CANCELLED', 'RATE_LIMITED',
|
| 146 |
-
})
|
| 147 |
|
| 148 |
# ── Telemetry & Health ────────────────────────────────────────────────────────
|
| 149 |
_ai_health_cache: dict = {"data": None, "at": 0.0}
|
|
@@ -249,7 +244,7 @@ def _prune_checkpoints() -> None:
|
|
| 249 |
def _prune_agent_tasks() -> None:
|
| 250 |
now = int(time.time() * 1000)
|
| 251 |
expired = [k for k, v in list(_agent_tasks.items())
|
| 252 |
-
if v.get('status') in
|
| 253 |
and now - v.get('created_at', 0) > _AGENT_TASK_TTL_MS]
|
| 254 |
for k in expired:
|
| 255 |
_agent_tasks.pop(k, None)
|
|
|
|
| 139 |
_CHECKPOINT_MAX = 100
|
| 140 |
_AGENT_TASK_TTL_MS = 2 * 60 * 60 * 1000
|
| 141 |
_AGENT_TASK_MAX = 200
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
# ── Telemetry & Health ────────────────────────────────────────────────────────
|
| 144 |
_ai_health_cache: dict = {"data": None, "at": 0.0}
|
|
|
|
| 244 |
def _prune_agent_tasks() -> None:
|
| 245 |
now = int(time.time() * 1000)
|
| 246 |
expired = [k for k, v in list(_agent_tasks.items())
|
| 247 |
+
if v.get('status') in ('SUCCESS', 'ERROR', 'CANCELLED')
|
| 248 |
and now - v.get('created_at', 0) > _AGENT_TASK_TTL_MS]
|
| 249 |
for k in expired:
|
| 250 |
_agent_tasks.pop(k, None)
|
benchmark-extended.mjs
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
cors_policy.py
DELETED
|
@@ -1,61 +0,0 @@
|
|
| 1 |
-
"""CORS policy shared by the FastAPI entrypoint.
|
| 2 |
-
|
| 3 |
-
The backend authenticates browser calls with explicit bearer/internal headers,
|
| 4 |
-
not with ambient browser cookies. Credentials therefore remain disabled unless
|
| 5 |
-
an explicit future policy requires them.
|
| 6 |
-
"""
|
| 7 |
-
from __future__ import annotations
|
| 8 |
-
|
| 9 |
-
import os
|
| 10 |
-
from urllib.parse import urlsplit
|
| 11 |
-
|
| 12 |
-
PUBLIC_FRONTEND_ORIGIN = "https://agente-ai.pages.dev"
|
| 13 |
-
_DEVELOPMENT_ORIGINS = frozenset(
|
| 14 |
-
{
|
| 15 |
-
"http://localhost:3000",
|
| 16 |
-
"http://localhost:5173",
|
| 17 |
-
"http://127.0.0.1:3000",
|
| 18 |
-
"http://127.0.0.1:5173",
|
| 19 |
-
}
|
| 20 |
-
)
|
| 21 |
-
_DEVELOPMENT_ENVS = frozenset({"dev", "development", "local", "test"})
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
def _is_valid_origin(origin: str) -> bool:
|
| 25 |
-
"""Return True only for an exact HTTP(S) origin without path or wildcard."""
|
| 26 |
-
if not origin or origin == "*" or any(char.isspace() for char in origin):
|
| 27 |
-
return False
|
| 28 |
-
parsed = urlsplit(origin)
|
| 29 |
-
return bool(
|
| 30 |
-
parsed.scheme in {"http", "https"}
|
| 31 |
-
and parsed.netloc
|
| 32 |
-
and not parsed.username
|
| 33 |
-
and not parsed.password
|
| 34 |
-
and not parsed.path
|
| 35 |
-
and not parsed.query
|
| 36 |
-
and not parsed.fragment
|
| 37 |
-
)
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
def allowed_origins(raw: str | None = None, environment: str | None = None) -> list[str]:
|
| 41 |
-
"""Build the exact CORS allow-list from environment and safe defaults.
|
| 42 |
-
|
| 43 |
-
``CORS_ALLOWED_ORIGINS`` is a comma-separated list of exact origins. An
|
| 44 |
-
invalid value, including ``*``, is ignored rather than widening access.
|
| 45 |
-
Local development origins are enabled only for an explicit development or
|
| 46 |
-
test environment. The production default is the canonical Pages origin.
|
| 47 |
-
"""
|
| 48 |
-
configured = raw if raw is not None else os.getenv("CORS_ALLOWED_ORIGINS", "")
|
| 49 |
-
origins = [
|
| 50 |
-
origin.strip().rstrip("/")
|
| 51 |
-
for origin in configured.split(",")
|
| 52 |
-
if origin.strip()
|
| 53 |
-
]
|
| 54 |
-
valid = {origin for origin in origins if _is_valid_origin(origin)}
|
| 55 |
-
|
| 56 |
-
env_name = (environment if environment is not None else os.getenv("APP_ENV", os.getenv("ENVIRONMENT", ""))).strip().lower()
|
| 57 |
-
if not valid:
|
| 58 |
-
valid.add(PUBLIC_FRONTEND_ORIGIN)
|
| 59 |
-
if env_name in _DEVELOPMENT_ENVS:
|
| 60 |
-
valid.update(_DEVELOPMENT_ORIGINS)
|
| 61 |
-
return sorted(valid)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
main.py
CHANGED
|
@@ -10,7 +10,6 @@ from fastapi import FastAPI
|
|
| 10 |
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
from fastapi.staticfiles import StaticFiles
|
| 12 |
from api.version import RUNTIME_VERSION
|
| 13 |
-
from cors_policy import allowed_origins
|
| 14 |
|
| 15 |
# Configurazione Logging
|
| 16 |
logging.basicConfig(
|
|
@@ -26,14 +25,13 @@ app = FastAPI(
|
|
| 26 |
version=RUNTIME_VERSION,
|
| 27 |
)
|
| 28 |
|
| 29 |
-
# CORS
|
| 30 |
-
# non cookie cross-origin; le credenziali browser restano quindi disabilitate.
|
| 31 |
app.add_middleware(
|
| 32 |
CORSMiddleware,
|
| 33 |
-
allow_origins=
|
| 34 |
-
allow_credentials=
|
| 35 |
-
allow_methods=["
|
| 36 |
-
allow_headers=["
|
| 37 |
)
|
| 38 |
|
| 39 |
# ── P17-F1: RLS Fix & Auto-Migration ──────────────────────────────────────────
|
|
@@ -246,20 +244,6 @@ async def run_cli_task(task_description: str):
|
|
| 246 |
sys.exit(1)
|
| 247 |
|
| 248 |
# ── Startup ───────────────────────────────────────────────────────────────────
|
| 249 |
-
async def _snapshot_heartbeat() -> None:
|
| 250 |
-
"""Keep the public dashboard snapshot fresh without affecting request handling."""
|
| 251 |
-
from api.public_snapshot import refresh_public_dashboard_snapshot
|
| 252 |
-
|
| 253 |
-
while True:
|
| 254 |
-
await asyncio.sleep(30)
|
| 255 |
-
try:
|
| 256 |
-
await asyncio.wait_for(refresh_public_dashboard_snapshot(), timeout=15.0)
|
| 257 |
-
except asyncio.CancelledError:
|
| 258 |
-
raise
|
| 259 |
-
except Exception as exc:
|
| 260 |
-
_logger.debug("Public snapshot heartbeat failed (non-blocking): %s", exc)
|
| 261 |
-
|
| 262 |
-
|
| 263 |
@app.on_event("startup")
|
| 264 |
async def startup_event():
|
| 265 |
_logger.info("Server starting up...")
|
|
@@ -269,26 +253,16 @@ async def startup_event():
|
|
| 269 |
_logger.info("✅ BOOT: apply_rls_fix_sync() eseguito con successo.")
|
| 270 |
except Exception as e:
|
| 271 |
_logger.warning(f"⚠️ BOOT: apply_rls_fix_sync() fallito (non bloccante): {e}")
|
| 272 |
-
|
| 273 |
-
# Non avviare auto-migrazioni in background: il boot deve restare
|
| 274 |
-
# deterministico e non può dichiarare successo prima della migrazione.
|
| 275 |
try:
|
| 276 |
from api.public_snapshot import write_public_dashboard_snapshot
|
| 277 |
-
snapshot_ok = await asyncio.wait_for(write_public_dashboard_snapshot(), timeout=
|
| 278 |
if snapshot_ok:
|
| 279 |
_logger.info("✅ BOOT: public dashboard snapshot writer completato.")
|
| 280 |
else:
|
| 281 |
_logger.warning("⚠️ BOOT: public dashboard snapshot writer non ha persistito lo snapshot.")
|
| 282 |
except Exception as e:
|
| 283 |
_logger.warning(f"⚠️ BOOT: avvio public snapshot writer fallito (non bloccante): {e}")
|
| 284 |
-
try:
|
| 285 |
-
from api.background_tasks import spawn_background_task
|
| 286 |
-
from api.telemetry import telemetry_alert_loop
|
| 287 |
-
spawn_background_task(telemetry_alert_loop(), name="telemetry-alert-loop")
|
| 288 |
-
spawn_background_task(_snapshot_heartbeat(), name="public-snapshot-heartbeat")
|
| 289 |
-
_logger.info("✅ BOOT: telemetry e snapshot loops supervisionati.")
|
| 290 |
-
except Exception as e:
|
| 291 |
-
_logger.warning(f"⚠️ BOOT: avvio telemetry/snapshot loops fallito (non bloccante): {e}")
|
| 292 |
try:
|
| 293 |
from api.providers import start_heartbeat
|
| 294 |
start_heartbeat()
|
|
@@ -297,24 +271,9 @@ async def startup_event():
|
|
| 297 |
_logger.warning(f"⚠️ BOOT: avvio provider heartbeat fallito (non bloccante): {e}")
|
| 298 |
if not any(arg in sys.argv for arg in ["--task", "-t"]):
|
| 299 |
try:
|
| 300 |
-
from api.background_tasks import spawn_background_task
|
| 301 |
from api.job_queue import start_job_queue_consumer
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
)
|
| 305 |
-
except Exception as e:
|
| 306 |
-
_logger.warning("⚠️ BOOT: avvio job queue fallito (non bloccante): %s", e)
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
@app.on_event("shutdown")
|
| 310 |
-
async def shutdown_event():
|
| 311 |
-
"""Cancel and await all registered background tasks before loop shutdown."""
|
| 312 |
-
try:
|
| 313 |
-
from api.background_tasks import shutdown_background_tasks
|
| 314 |
-
await shutdown_background_tasks()
|
| 315 |
-
_logger.info("✅ SHUTDOWN: background tasks supervisionate arrestate.")
|
| 316 |
-
except Exception as e:
|
| 317 |
-
_logger.warning("⚠️ SHUTDOWN: arresto background tasks incompleto: %s", e)
|
| 318 |
|
| 319 |
# ── SPA Hosting ────────────────────────────────────────────────────��──────────
|
| 320 |
_STATIC_DIR = os.getenv('FRONTEND_DIST', '/app/backend/static')
|
|
|
|
| 10 |
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
from fastapi.staticfiles import StaticFiles
|
| 12 |
from api.version import RUNTIME_VERSION
|
|
|
|
| 13 |
|
| 14 |
# Configurazione Logging
|
| 15 |
logging.basicConfig(
|
|
|
|
| 25 |
version=RUNTIME_VERSION,
|
| 26 |
)
|
| 27 |
|
| 28 |
+
# CORS
|
|
|
|
| 29 |
app.add_middleware(
|
| 30 |
CORSMiddleware,
|
| 31 |
+
allow_origins=["*"],
|
| 32 |
+
allow_credentials=True,
|
| 33 |
+
allow_methods=["*"],
|
| 34 |
+
allow_headers=["*"],
|
| 35 |
)
|
| 36 |
|
| 37 |
# ── P17-F1: RLS Fix & Auto-Migration ──────────────────────────────────────────
|
|
|
|
| 244 |
sys.exit(1)
|
| 245 |
|
| 246 |
# ── Startup ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
@app.on_event("startup")
|
| 248 |
async def startup_event():
|
| 249 |
_logger.info("Server starting up...")
|
|
|
|
| 253 |
_logger.info("✅ BOOT: apply_rls_fix_sync() eseguito con successo.")
|
| 254 |
except Exception as e:
|
| 255 |
_logger.warning(f"⚠️ BOOT: apply_rls_fix_sync() fallito (non bloccante): {e}")
|
| 256 |
+
asyncio.create_task(_run_auto_migration())
|
|
|
|
|
|
|
| 257 |
try:
|
| 258 |
from api.public_snapshot import write_public_dashboard_snapshot
|
| 259 |
+
snapshot_ok = await asyncio.wait_for(write_public_dashboard_snapshot(), timeout=12.0)
|
| 260 |
if snapshot_ok:
|
| 261 |
_logger.info("✅ BOOT: public dashboard snapshot writer completato.")
|
| 262 |
else:
|
| 263 |
_logger.warning("⚠️ BOOT: public dashboard snapshot writer non ha persistito lo snapshot.")
|
| 264 |
except Exception as e:
|
| 265 |
_logger.warning(f"⚠️ BOOT: avvio public snapshot writer fallito (non bloccante): {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
try:
|
| 267 |
from api.providers import start_heartbeat
|
| 268 |
start_heartbeat()
|
|
|
|
| 271 |
_logger.warning(f"⚠️ BOOT: avvio provider heartbeat fallito (non bloccante): {e}")
|
| 272 |
if not any(arg in sys.argv for arg in ["--task", "-t"]):
|
| 273 |
try:
|
|
|
|
| 274 |
from api.job_queue import start_job_queue_consumer
|
| 275 |
+
asyncio.create_task(start_job_queue_consumer())
|
| 276 |
+
except Exception: pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
|
| 278 |
# ── SPA Hosting ────────────────────────────────────────────────────��──────────
|
| 279 |
_STATIC_DIR = os.getenv('FRONTEND_DIST', '/app/backend/static')
|
scripts/lib/report-engine.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
| 2 |
+
import { join, resolve } from "node:path";
|
| 3 |
+
|
| 4 |
+
const DEFAULT_REPORT_DIRECTORY = "/tmp/agente-ai";
|
| 5 |
+
|
| 6 |
+
function safeSegment(value) {
|
| 7 |
+
return String(value ?? "benchmark")
|
| 8 |
+
.trim()
|
| 9 |
+
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
| 10 |
+
.replace(/^-+|-+$/g, "")
|
| 11 |
+
.slice(0, 96) || "benchmark";
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* Persiste un artefatto diagnostico del runner senza incidere sui calcoli.
|
| 16 |
+
* Il write-then-rename evita file JSON parziali in caso di interruzione.
|
| 17 |
+
*/
|
| 18 |
+
export function saveBenchmarkReport(reportName, payload) {
|
| 19 |
+
const reportDirectory = resolve(process.env.BENCHMARK_REPORT_DIR || DEFAULT_REPORT_DIRECTORY);
|
| 20 |
+
mkdirSync(reportDirectory, { recursive: true });
|
| 21 |
+
|
| 22 |
+
const safeName = safeSegment(reportName);
|
| 23 |
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
| 24 |
+
const finalPath = join(reportDirectory, `${safeName}-${stamp}.json`);
|
| 25 |
+
const temporaryPath = `${finalPath}.${process.pid}.tmp`;
|
| 26 |
+
const document = {
|
| 27 |
+
schemaVersion: "benchmark-report-v1",
|
| 28 |
+
generatedAt: new Date().toISOString(),
|
| 29 |
+
reportName: safeName,
|
| 30 |
+
...payload,
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
writeFileSync(temporaryPath, `${JSON.stringify(document, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
| 34 |
+
renameSync(temporaryPath, finalPath);
|
| 35 |
+
return finalPath;
|
| 36 |
+
}
|
tests/test_agent_task_pruning.py
DELETED
|
@@ -1,70 +0,0 @@
|
|
| 1 |
-
import unittest
|
| 2 |
-
from unittest.mock import patch
|
| 3 |
-
|
| 4 |
-
import api.state as state
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
_TERMINAL_STATES = (
|
| 8 |
-
"SUCCESS",
|
| 9 |
-
"COMPLETED",
|
| 10 |
-
"ERROR",
|
| 11 |
-
"CANCELLED",
|
| 12 |
-
"RATE_LIMITED",
|
| 13 |
-
)
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
class AgentTaskPruningTests(unittest.TestCase):
|
| 17 |
-
def test_prune_expires_every_terminal_state_and_cleans_byok(self):
|
| 18 |
-
now_ms = 10_000_000
|
| 19 |
-
old_ms = now_ms - state._AGENT_TASK_TTL_MS - 1
|
| 20 |
-
task_ids = {f"old-{status.lower()}" for status in _TERMINAL_STATES}
|
| 21 |
-
task_ids.update({"running", "queued"})
|
| 22 |
-
tasks = {
|
| 23 |
-
task_id: {
|
| 24 |
-
"status": (
|
| 25 |
-
task_id.removeprefix("old-").upper()
|
| 26 |
-
if task_id.startswith("old-")
|
| 27 |
-
else task_id.upper()
|
| 28 |
-
),
|
| 29 |
-
"created_at": old_ms,
|
| 30 |
-
}
|
| 31 |
-
for task_id in task_ids
|
| 32 |
-
}
|
| 33 |
-
byok_clients = {task_id: object() for task_id in task_ids}
|
| 34 |
-
|
| 35 |
-
with patch.object(state, "_agent_tasks", tasks), patch.object(
|
| 36 |
-
state, "_task_ai_clients", byok_clients
|
| 37 |
-
), patch.object(state.time, "time", return_value=now_ms / 1000):
|
| 38 |
-
state._prune_agent_tasks()
|
| 39 |
-
self.assertEqual(set(state._agent_tasks), {"running", "queued"})
|
| 40 |
-
self.assertEqual(set(state._task_ai_clients), {"running", "queued"})
|
| 41 |
-
|
| 42 |
-
def test_prune_keeps_recent_terminal_tasks(self):
|
| 43 |
-
now_ms = 10_000_000
|
| 44 |
-
recent_ms = now_ms - state._AGENT_TASK_TTL_MS + 1
|
| 45 |
-
tasks = {
|
| 46 |
-
status.lower(): {"status": status, "created_at": recent_ms}
|
| 47 |
-
for status in _TERMINAL_STATES
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
with patch.object(state, "_agent_tasks", tasks), patch.object(
|
| 51 |
-
state, "_task_ai_clients", {}
|
| 52 |
-
), patch.object(state.time, "time", return_value=now_ms / 1000):
|
| 53 |
-
state._prune_agent_tasks()
|
| 54 |
-
self.assertEqual(
|
| 55 |
-
set(state._agent_tasks),
|
| 56 |
-
{status.lower() for status in _TERMINAL_STATES},
|
| 57 |
-
)
|
| 58 |
-
|
| 59 |
-
def test_terminal_state_set_matches_pruning_contract(self):
|
| 60 |
-
self.assertEqual(
|
| 61 |
-
state._AGENT_TASK_TERMINAL_STATES,
|
| 62 |
-
frozenset(_TERMINAL_STATES),
|
| 63 |
-
)
|
| 64 |
-
self.assertNotIn("QUEUED", state._AGENT_TASK_TERMINAL_STATES)
|
| 65 |
-
self.assertNotIn("RUNNING", state._AGENT_TASK_TERMINAL_STATES)
|
| 66 |
-
self.assertNotIn("CREATING", state._AGENT_TASK_TERMINAL_STATES)
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
if __name__ == "__main__":
|
| 70 |
-
unittest.main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_ai_provider_health_access.py
DELETED
|
@@ -1,83 +0,0 @@
|
|
| 1 |
-
"""Regressioni di accesso alla diagnostica dettagliata dei provider.
|
| 2 |
-
|
| 3 |
-
Esegui con: python3 -m unittest backend.tests.test_ai_provider_health_access -v
|
| 4 |
-
"""
|
| 5 |
-
from __future__ import annotations
|
| 6 |
-
|
| 7 |
-
import os
|
| 8 |
-
import sys
|
| 9 |
-
import time
|
| 10 |
-
import unittest
|
| 11 |
-
from unittest.mock import patch
|
| 12 |
-
|
| 13 |
-
from fastapi import FastAPI
|
| 14 |
-
from fastapi.testclient import TestClient
|
| 15 |
-
|
| 16 |
-
_BACKEND = os.path.join(os.path.dirname(__file__), "..")
|
| 17 |
-
if _BACKEND not in sys.path:
|
| 18 |
-
sys.path.insert(0, _BACKEND)
|
| 19 |
-
|
| 20 |
-
from api import auth_guard, providers
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
class TestAIProviderHealthAccess(unittest.TestCase):
|
| 24 |
-
"""Il payload con profili, quote e dettagli upstream è solo per OPERATOR."""
|
| 25 |
-
|
| 26 |
-
@classmethod
|
| 27 |
-
def setUpClass(cls) -> None:
|
| 28 |
-
app = FastAPI()
|
| 29 |
-
app.include_router(providers.router)
|
| 30 |
-
cls.client = TestClient(app)
|
| 31 |
-
|
| 32 |
-
def setUp(self) -> None:
|
| 33 |
-
self.env_patch = patch.dict(
|
| 34 |
-
os.environ,
|
| 35 |
-
{
|
| 36 |
-
"INTERNAL_TOKEN": "test-internal-token",
|
| 37 |
-
"OPERATOR_TOKEN": "test-operator-token",
|
| 38 |
-
},
|
| 39 |
-
clear=False,
|
| 40 |
-
)
|
| 41 |
-
self.env_patch.start()
|
| 42 |
-
self.previous_health_cache = dict(providers._ai_health_cache)
|
| 43 |
-
auth_guard._rate_store.clear()
|
| 44 |
-
auth_guard._rate_store_checks = 0
|
| 45 |
-
|
| 46 |
-
def tearDown(self) -> None:
|
| 47 |
-
self.env_patch.stop()
|
| 48 |
-
providers._ai_health_cache.clear()
|
| 49 |
-
providers._ai_health_cache.update(self.previous_health_cache)
|
| 50 |
-
auth_guard._rate_store.clear()
|
| 51 |
-
auth_guard._rate_store_checks = 0
|
| 52 |
-
|
| 53 |
-
def test_anonymous_browser_cannot_request_detailed_provider_diagnostics(self) -> None:
|
| 54 |
-
response = self.client.get("/api/ai/health")
|
| 55 |
-
|
| 56 |
-
self.assertEqual(response.status_code, 403)
|
| 57 |
-
self.assertEqual(response.json()["detail"]["required_role"], "OPERATOR")
|
| 58 |
-
|
| 59 |
-
def test_proxy_machine_token_cannot_escalate_browser_to_provider_diagnostics(self) -> None:
|
| 60 |
-
response = self.client.get(
|
| 61 |
-
"/api/ai/health",
|
| 62 |
-
headers={"X-Internal-Token": "test-internal-token"},
|
| 63 |
-
)
|
| 64 |
-
|
| 65 |
-
self.assertEqual(response.status_code, 403)
|
| 66 |
-
self.assertEqual(response.json()["detail"]["your_role"], "MACHINE")
|
| 67 |
-
self.assertEqual(response.json()["detail"]["required_role"], "OPERATOR")
|
| 68 |
-
|
| 69 |
-
def test_operator_can_read_cached_diagnostics_without_triggering_a_probe(self) -> None:
|
| 70 |
-
providers._ai_health_cache["data"] = {"providers": [], "tested_at": 1}
|
| 71 |
-
providers._ai_health_cache["at"] = time.monotonic()
|
| 72 |
-
|
| 73 |
-
response = self.client.get(
|
| 74 |
-
"/api/ai/health",
|
| 75 |
-
headers={"X-Operator-Token": "test-operator-token"},
|
| 76 |
-
)
|
| 77 |
-
|
| 78 |
-
self.assertEqual(response.status_code, 200)
|
| 79 |
-
self.assertEqual(response.json(), {"providers": [], "tested_at": 1})
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
if __name__ == "__main__":
|
| 83 |
-
unittest.main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_background_tasks.py
DELETED
|
@@ -1,57 +0,0 @@
|
|
| 1 |
-
import asyncio
|
| 2 |
-
import unittest
|
| 3 |
-
|
| 4 |
-
from api import background_tasks
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
class BackgroundTaskSupervisorTests(unittest.IsolatedAsyncioTestCase):
|
| 8 |
-
async def asyncSetUp(self):
|
| 9 |
-
await background_tasks.shutdown_background_tasks()
|
| 10 |
-
|
| 11 |
-
async def asyncTearDown(self):
|
| 12 |
-
await background_tasks.shutdown_background_tasks()
|
| 13 |
-
|
| 14 |
-
async def test_duplicate_name_reuses_task_and_closes_duplicate_coroutine(self):
|
| 15 |
-
started = asyncio.Event()
|
| 16 |
-
release = asyncio.Event()
|
| 17 |
-
|
| 18 |
-
async def worker():
|
| 19 |
-
started.set()
|
| 20 |
-
await release.wait()
|
| 21 |
-
|
| 22 |
-
first = background_tasks.spawn_background_task(worker(), name="duplicate")
|
| 23 |
-
await started.wait()
|
| 24 |
-
duplicate = background_tasks.spawn_background_task(worker(), name="duplicate")
|
| 25 |
-
self.assertIs(first, duplicate)
|
| 26 |
-
release.set()
|
| 27 |
-
await background_tasks.shutdown_background_tasks()
|
| 28 |
-
self.assertTrue(first.done())
|
| 29 |
-
|
| 30 |
-
async def test_shutdown_cancels_and_awaits_running_task(self):
|
| 31 |
-
cancelled = asyncio.Event()
|
| 32 |
-
|
| 33 |
-
async def worker():
|
| 34 |
-
try:
|
| 35 |
-
await asyncio.Event().wait()
|
| 36 |
-
except asyncio.CancelledError:
|
| 37 |
-
cancelled.set()
|
| 38 |
-
raise
|
| 39 |
-
|
| 40 |
-
background_tasks.spawn_background_task(worker(), name="cancellable")
|
| 41 |
-
await asyncio.sleep(0)
|
| 42 |
-
await background_tasks.shutdown_background_tasks()
|
| 43 |
-
self.assertTrue(cancelled.is_set())
|
| 44 |
-
self.assertFalse(background_tasks._tasks)
|
| 45 |
-
|
| 46 |
-
async def test_completed_task_is_not_left_in_registry_after_shutdown(self):
|
| 47 |
-
async def worker():
|
| 48 |
-
return "ok"
|
| 49 |
-
|
| 50 |
-
task = background_tasks.spawn_background_task(worker(), name="completed")
|
| 51 |
-
await task
|
| 52 |
-
await background_tasks.shutdown_background_tasks()
|
| 53 |
-
self.assertFalse(background_tasks._tasks)
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
if __name__ == "__main__":
|
| 57 |
-
unittest.main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_cors_policy.py
DELETED
|
@@ -1,65 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import unittest
|
| 4 |
-
|
| 5 |
-
from fastapi import FastAPI
|
| 6 |
-
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
-
from fastapi.testclient import TestClient
|
| 8 |
-
|
| 9 |
-
from cors_policy import PUBLIC_FRONTEND_ORIGIN, allowed_origins
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
class CorsPolicyTests(unittest.TestCase):
|
| 13 |
-
def test_production_defaults_to_canonical_frontend_only(self) -> None:
|
| 14 |
-
self.assertEqual(allowed_origins(raw="", environment="production"), [PUBLIC_FRONTEND_ORIGIN])
|
| 15 |
-
|
| 16 |
-
def test_custom_origins_are_exact_and_wildcards_are_ignored(self) -> None:
|
| 17 |
-
origins = allowed_origins(
|
| 18 |
-
raw=" https://agente-ai.pages.dev/ , * , https://evil.example/path , http://localhost:5173 ",
|
| 19 |
-
environment="production",
|
| 20 |
-
)
|
| 21 |
-
self.assertEqual(origins, ["http://localhost:5173", PUBLIC_FRONTEND_ORIGIN])
|
| 22 |
-
|
| 23 |
-
def test_local_origins_are_available_only_in_development(self) -> None:
|
| 24 |
-
production = allowed_origins(raw="", environment="production")
|
| 25 |
-
development = allowed_origins(raw="", environment="development")
|
| 26 |
-
self.assertNotIn("http://localhost:5173", production)
|
| 27 |
-
self.assertIn("http://localhost:5173", development)
|
| 28 |
-
|
| 29 |
-
def test_invalid_configuration_fails_closed_to_public_default(self) -> None:
|
| 30 |
-
self.assertEqual(allowed_origins(raw="*", environment="production"), [PUBLIC_FRONTEND_ORIGIN])
|
| 31 |
-
|
| 32 |
-
def test_middleware_rejects_untrusted_origin_and_does_not_allow_credentials(self) -> None:
|
| 33 |
-
app = FastAPI()
|
| 34 |
-
app.add_middleware(
|
| 35 |
-
CORSMiddleware,
|
| 36 |
-
allow_origins=allowed_origins(raw=PUBLIC_FRONTEND_ORIGIN, environment="production"),
|
| 37 |
-
allow_credentials=False,
|
| 38 |
-
allow_methods=["GET", "OPTIONS"],
|
| 39 |
-
allow_headers=["Authorization", "Content-Type"],
|
| 40 |
-
)
|
| 41 |
-
|
| 42 |
-
@app.get("/health")
|
| 43 |
-
def health() -> dict[str, str]:
|
| 44 |
-
return {"status": "ok"}
|
| 45 |
-
|
| 46 |
-
client = TestClient(app)
|
| 47 |
-
trusted = client.get("/health", headers={"Origin": PUBLIC_FRONTEND_ORIGIN})
|
| 48 |
-
untrusted = client.get("/health", headers={"Origin": "https://audit-origin.invalid"})
|
| 49 |
-
preflight = client.options(
|
| 50 |
-
"/health",
|
| 51 |
-
headers={
|
| 52 |
-
"Origin": "https://audit-origin.invalid",
|
| 53 |
-
"Access-Control-Request-Method": "GET",
|
| 54 |
-
"Access-Control-Request-Headers": "authorization",
|
| 55 |
-
},
|
| 56 |
-
)
|
| 57 |
-
|
| 58 |
-
self.assertEqual(trusted.headers.get("access-control-allow-origin"), PUBLIC_FRONTEND_ORIGIN)
|
| 59 |
-
self.assertNotIn("access-control-allow-credentials", trusted.headers)
|
| 60 |
-
self.assertNotIn("access-control-allow-origin", untrusted.headers)
|
| 61 |
-
self.assertNotEqual(preflight.headers.get("access-control-allow-origin"), "https://audit-origin.invalid")
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
if __name__ == "__main__":
|
| 65 |
-
unittest.main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_executor_side_effect_retry.py
DELETED
|
@@ -1,91 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import unittest
|
| 4 |
-
from unittest.mock import patch
|
| 5 |
-
|
| 6 |
-
from agents import executor as executor_module
|
| 7 |
-
from agents.executor import Executor
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
class FailingMemory:
|
| 11 |
-
async def save_episode(self, *_args, **_kwargs) -> None:
|
| 12 |
-
raise RuntimeError("memory unavailable")
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
class RecordingMemory:
|
| 16 |
-
def __init__(self) -> None:
|
| 17 |
-
self.calls = 0
|
| 18 |
-
|
| 19 |
-
async def save_episode(self, *_args, **_kwargs) -> None:
|
| 20 |
-
self.calls += 1
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
class ExecutorSideEffectRetryTests(unittest.IsolatedAsyncioTestCase):
|
| 24 |
-
async def test_memory_failure_does_not_retry_completed_side_effect(self) -> None:
|
| 25 |
-
calls = 0
|
| 26 |
-
|
| 27 |
-
async def non_idempotent_tool(**_inputs):
|
| 28 |
-
nonlocal calls
|
| 29 |
-
calls += 1
|
| 30 |
-
return {"created_id": "resource-1"}
|
| 31 |
-
|
| 32 |
-
with patch.dict(
|
| 33 |
-
executor_module.TOOL_REGISTRY,
|
| 34 |
-
{"non_idempotent_tool": {"required_inputs": [], "fallbacks": [], "_fn": non_idempotent_tool}},
|
| 35 |
-
clear=False,
|
| 36 |
-
):
|
| 37 |
-
result = await Executor(llm_client=object(), memory=FailingMemory(), max_retries=2).run_tool(
|
| 38 |
-
"non_idempotent_tool", {}, timeout=2, worker_hint="test",
|
| 39 |
-
)
|
| 40 |
-
|
| 41 |
-
self.assertTrue(result["success"])
|
| 42 |
-
self.assertEqual(result["attempt"], 1)
|
| 43 |
-
self.assertEqual(calls, 1)
|
| 44 |
-
self.assertFalse(result["memory_persisted"])
|
| 45 |
-
self.assertIn("memory unavailable", result["memory_error"])
|
| 46 |
-
|
| 47 |
-
async def test_tool_failure_still_retries_before_any_side_effect_result(self) -> None:
|
| 48 |
-
calls = 0
|
| 49 |
-
|
| 50 |
-
async def flaky_tool(**_inputs):
|
| 51 |
-
nonlocal calls
|
| 52 |
-
calls += 1
|
| 53 |
-
if calls == 1:
|
| 54 |
-
raise RuntimeError("transient tool failure")
|
| 55 |
-
return {"ok": True}
|
| 56 |
-
|
| 57 |
-
with patch.dict(
|
| 58 |
-
executor_module.TOOL_REGISTRY,
|
| 59 |
-
{"flaky_tool": {"required_inputs": [], "fallbacks": [], "_fn": flaky_tool}},
|
| 60 |
-
clear=False,
|
| 61 |
-
):
|
| 62 |
-
result = await Executor(llm_client=object(), memory=None, max_retries=2).run_tool(
|
| 63 |
-
"flaky_tool", {}, timeout=2, worker_hint="test",
|
| 64 |
-
)
|
| 65 |
-
|
| 66 |
-
self.assertTrue(result["success"])
|
| 67 |
-
self.assertEqual(result["attempt"], 2)
|
| 68 |
-
self.assertEqual(calls, 2)
|
| 69 |
-
|
| 70 |
-
async def test_successful_memory_persistence_is_reported(self) -> None:
|
| 71 |
-
memory = RecordingMemory()
|
| 72 |
-
|
| 73 |
-
async def safe_tool(**_inputs):
|
| 74 |
-
return "done"
|
| 75 |
-
|
| 76 |
-
with patch.dict(
|
| 77 |
-
executor_module.TOOL_REGISTRY,
|
| 78 |
-
{"safe_tool": {"required_inputs": [], "fallbacks": [], "_fn": safe_tool}},
|
| 79 |
-
clear=False,
|
| 80 |
-
):
|
| 81 |
-
result = await Executor(llm_client=object(), memory=memory, max_retries=0).run_tool(
|
| 82 |
-
"safe_tool", {}, timeout=2, worker_hint="test",
|
| 83 |
-
)
|
| 84 |
-
|
| 85 |
-
self.assertTrue(result["success"])
|
| 86 |
-
self.assertTrue(result["memory_persisted"])
|
| 87 |
-
self.assertEqual(memory.calls, 1)
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
if __name__ == "__main__":
|
| 91 |
-
unittest.main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_model_watch_adapter.py
CHANGED
|
@@ -171,14 +171,14 @@ class GeminiModelsAdapterTests(unittest.IsolatedAsyncioTestCase):
|
|
| 171 |
provider="gemini",
|
| 172 |
profile=profile,
|
| 173 |
base_url="https://generativelanguage.googleapis.com/v1beta",
|
| 174 |
-
api_key="
|
| 175 |
default_model=default_model,
|
| 176 |
auth_mode="query_key",
|
| 177 |
)
|
| 178 |
|
| 179 |
async def test_native_models_payload_is_parsed_and_prefix_removed(self):
|
| 180 |
async def handler(request):
|
| 181 |
-
self.assertEqual(request.url.params.get("key"), "
|
| 182 |
return httpx.Response(200, json={"models": [
|
| 183 |
{"name": "models/gemini-3.6-flash"},
|
| 184 |
{"name": "models/gemini-3.5-flash"},
|
|
|
|
| 171 |
provider="gemini",
|
| 172 |
profile=profile,
|
| 173 |
base_url="https://generativelanguage.googleapis.com/v1beta",
|
| 174 |
+
api_key="gemini-secret-not-logged",
|
| 175 |
default_model=default_model,
|
| 176 |
auth_mode="query_key",
|
| 177 |
)
|
| 178 |
|
| 179 |
async def test_native_models_payload_is_parsed_and_prefix_removed(self):
|
| 180 |
async def handler(request):
|
| 181 |
+
self.assertEqual(request.url.params.get("key"), "gemini-secret-not-logged")
|
| 182 |
return httpx.Response(200, json={"models": [
|
| 183 |
{"name": "models/gemini-3.6-flash"},
|
| 184 |
{"name": "models/gemini-3.5-flash"},
|
tests/test_public_snapshot_writer.py
CHANGED
|
@@ -4,14 +4,10 @@ from api import public_snapshot
|
|
| 4 |
|
| 5 |
|
| 6 |
class _FakeTable:
|
| 7 |
-
def __init__(self, calls
|
| 8 |
self.calls = calls
|
| 9 |
-
self.failures = failures
|
| 10 |
|
| 11 |
def upsert(self, row, on_conflict=None):
|
| 12 |
-
if self.failures:
|
| 13 |
-
self.failures -= 1
|
| 14 |
-
raise RuntimeError("temporary Supabase failure")
|
| 15 |
self.calls.append((row, on_conflict))
|
| 16 |
return self
|
| 17 |
|
|
@@ -20,22 +16,17 @@ class _FakeTable:
|
|
| 20 |
|
| 21 |
|
| 22 |
class _FakeClient:
|
| 23 |
-
def __init__(self
|
| 24 |
self.calls = []
|
| 25 |
-
self.failures = failures
|
| 26 |
|
| 27 |
def table(self, name):
|
| 28 |
assert name == "public_dashboard_snapshot"
|
| 29 |
-
return _FakeTable(self.calls
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
def _run(coro):
|
| 33 |
-
return asyncio.run(coro)
|
| 34 |
|
| 35 |
|
| 36 |
def test_snapshot_writer_is_idempotent_and_counts_runtime_state(monkeypatch):
|
| 37 |
fake = _FakeClient()
|
| 38 |
-
monkeypatch.setattr(public_snapshot, "
|
| 39 |
monkeypatch.setattr(
|
| 40 |
public_snapshot,
|
| 41 |
"_agent_tasks",
|
|
@@ -47,8 +38,8 @@ def test_snapshot_writer_is_idempotent_and_counts_runtime_state(monkeypatch):
|
|
| 47 |
)
|
| 48 |
monkeypatch.setattr(public_snapshot, "_loop_registry", {"session-1": object()})
|
| 49 |
|
| 50 |
-
assert
|
| 51 |
-
assert
|
| 52 |
|
| 53 |
assert len(fake.calls) == 2
|
| 54 |
first, second = fake.calls
|
|
@@ -62,34 +53,6 @@ def test_snapshot_writer_is_idempotent_and_counts_runtime_state(monkeypatch):
|
|
| 62 |
assert first[0]["service_status"] == "operational"
|
| 63 |
|
| 64 |
|
| 65 |
-
def
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
class _RetryTable:
|
| 69 |
-
def upsert(self, row, on_conflict=None):
|
| 70 |
-
calls.append((row, on_conflict))
|
| 71 |
-
if len(calls) < 3:
|
| 72 |
-
raise RuntimeError("temporary Supabase failure")
|
| 73 |
-
return self
|
| 74 |
-
|
| 75 |
-
def execute(self):
|
| 76 |
-
return object()
|
| 77 |
-
|
| 78 |
-
class _RetryClient:
|
| 79 |
-
def table(self, name):
|
| 80 |
-
assert name == "public_dashboard_snapshot"
|
| 81 |
-
return _RetryTable()
|
| 82 |
-
|
| 83 |
-
monkeypatch.setattr(public_snapshot, "get_snapshot_client", lambda: _RetryClient())
|
| 84 |
-
async def _no_sleep(_seconds):
|
| 85 |
-
return None
|
| 86 |
-
|
| 87 |
-
monkeypatch.setattr(public_snapshot.asyncio, "sleep", _no_sleep)
|
| 88 |
-
|
| 89 |
-
assert _run(public_snapshot.write_public_dashboard_snapshot()) is True
|
| 90 |
-
assert len(calls) == 3
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
def test_snapshot_writer_returns_false_when_supabase_is_unavailable(monkeypatch):
|
| 94 |
-
monkeypatch.setattr(public_snapshot, "get_snapshot_client", lambda: None)
|
| 95 |
-
assert _run(public_snapshot.write_public_dashboard_snapshot()) is False
|
|
|
|
| 4 |
|
| 5 |
|
| 6 |
class _FakeTable:
|
| 7 |
+
def __init__(self, calls):
|
| 8 |
self.calls = calls
|
|
|
|
| 9 |
|
| 10 |
def upsert(self, row, on_conflict=None):
|
|
|
|
|
|
|
|
|
|
| 11 |
self.calls.append((row, on_conflict))
|
| 12 |
return self
|
| 13 |
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
class _FakeClient:
|
| 19 |
+
def __init__(self):
|
| 20 |
self.calls = []
|
|
|
|
| 21 |
|
| 22 |
def table(self, name):
|
| 23 |
assert name == "public_dashboard_snapshot"
|
| 24 |
+
return _FakeTable(self.calls)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
def test_snapshot_writer_is_idempotent_and_counts_runtime_state(monkeypatch):
|
| 28 |
fake = _FakeClient()
|
| 29 |
+
monkeypatch.setattr(public_snapshot, "sb", lambda: fake)
|
| 30 |
monkeypatch.setattr(
|
| 31 |
public_snapshot,
|
| 32 |
"_agent_tasks",
|
|
|
|
| 38 |
)
|
| 39 |
monkeypatch.setattr(public_snapshot, "_loop_registry", {"session-1": object()})
|
| 40 |
|
| 41 |
+
assert asyncio.run(public_snapshot.write_public_dashboard_snapshot()) is True
|
| 42 |
+
assert asyncio.run(public_snapshot.write_public_dashboard_snapshot()) is True
|
| 43 |
|
| 44 |
assert len(fake.calls) == 2
|
| 45 |
first, second = fake.calls
|
|
|
|
| 53 |
assert first[0]["service_status"] == "operational"
|
| 54 |
|
| 55 |
|
| 56 |
+
def test_snapshot_writer_is_non_blocking_when_supabase_is_unavailable(monkeypatch):
|
| 57 |
+
monkeypatch.setattr(public_snapshot, "sb", lambda: None)
|
| 58 |
+
assert asyncio.run(public_snapshot.write_public_dashboard_snapshot()) is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_shell_safety_pip.py
DELETED
|
@@ -1,32 +0,0 @@
|
|
| 1 |
-
"""Regression tests for the generic shell package-manager boundary."""
|
| 2 |
-
from __future__ import annotations
|
| 3 |
-
import unittest
|
| 4 |
-
|
| 5 |
-
from tools._shell_safety import validate_shell_command
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
class TestPipBlockedFromGenericShell(unittest.TestCase):
|
| 9 |
-
def test_blocks_pip_install(self):
|
| 10 |
-
self.assertIsNotNone(validate_shell_command("pip install requests"))
|
| 11 |
-
|
| 12 |
-
def test_blocks_pip3_install(self):
|
| 13 |
-
self.assertIsNotNone(validate_shell_command("pip3 install requests"))
|
| 14 |
-
|
| 15 |
-
def test_blocks_python_module_pip(self):
|
| 16 |
-
self.assertIsNotNone(validate_shell_command("python -m pip install requests"))
|
| 17 |
-
|
| 18 |
-
def test_blocks_python3_module_pip3(self):
|
| 19 |
-
self.assertIsNotNone(validate_shell_command("python3 -m pip3 install requests"))
|
| 20 |
-
|
| 21 |
-
def test_blocks_python_module_ensurepip(self):
|
| 22 |
-
self.assertIsNotNone(validate_shell_command("python -m ensurepip"))
|
| 23 |
-
|
| 24 |
-
def test_allows_python_without_package_manager(self):
|
| 25 |
-
self.assertIsNone(validate_shell_command("python -c 'print(1)'"))
|
| 26 |
-
|
| 27 |
-
def test_allows_pnpm(self):
|
| 28 |
-
self.assertIsNone(validate_shell_command("pnpm --version"))
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
if __name__ == "__main__":
|
| 32 |
-
unittest.main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_telemetry_alert_lifecycle.py
DELETED
|
@@ -1,45 +0,0 @@
|
|
| 1 |
-
import ast
|
| 2 |
-
import pathlib
|
| 3 |
-
import unittest
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
_MAIN = pathlib.Path(__file__).parents[1] / "main.py"
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
class TelemetryAlertLifecycleTests(unittest.TestCase):
|
| 10 |
-
@classmethod
|
| 11 |
-
def setUpClass(cls):
|
| 12 |
-
cls.tree = ast.parse(_MAIN.read_text(encoding="utf-8"))
|
| 13 |
-
cls.source = _MAIN.read_text(encoding="utf-8")
|
| 14 |
-
cls.functions = {
|
| 15 |
-
node.name: node
|
| 16 |
-
for node in cls.tree.body
|
| 17 |
-
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
| 18 |
-
}
|
| 19 |
-
|
| 20 |
-
def test_startup_registers_named_telemetry_task(self):
|
| 21 |
-
startup = self.functions["startup_event"]
|
| 22 |
-
source = ast.get_source_segment(self.source, startup)
|
| 23 |
-
self.assertIn("telemetry_alert_loop", source)
|
| 24 |
-
self.assertIn('name="telemetry-alert-loop"', source)
|
| 25 |
-
self.assertIn("_telemetry_alert_task", source)
|
| 26 |
-
|
| 27 |
-
def test_shutdown_cancels_and_awaits_telemetry_task(self):
|
| 28 |
-
shutdown = self.functions["shutdown_event"]
|
| 29 |
-
source = ast.get_source_segment(self.source, shutdown)
|
| 30 |
-
self.assertIn("task.cancel()", source)
|
| 31 |
-
self.assertIn("await task", source)
|
| 32 |
-
self.assertIn("asyncio.CancelledError", source)
|
| 33 |
-
|
| 34 |
-
def test_task_reference_is_module_scoped(self):
|
| 35 |
-
assignment_names = {
|
| 36 |
-
target.id
|
| 37 |
-
for node in self.tree.body
|
| 38 |
-
if isinstance(node, ast.AnnAssign)
|
| 39 |
-
and isinstance(target := node.target, ast.Name)
|
| 40 |
-
}
|
| 41 |
-
self.assertIn("_telemetry_alert_task", assignment_names)
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
if __name__ == "__main__":
|
| 45 |
-
unittest.main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_vfs_atomic_rollback.py
DELETED
|
@@ -1,76 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import asyncio
|
| 4 |
-
import tempfile
|
| 5 |
-
import unittest
|
| 6 |
-
from pathlib import Path
|
| 7 |
-
from unittest.mock import patch
|
| 8 |
-
|
| 9 |
-
from agents.unified_loop import UnifiedAgentLoop
|
| 10 |
-
from tools.registry import _delete_file, _read_file, _write_file
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
class FakeExecutor:
|
| 14 |
-
def __init__(self, failures: set[str] | None = None) -> None:
|
| 15 |
-
self.calls: list[tuple[str, dict]] = []
|
| 16 |
-
self.failures = failures or set()
|
| 17 |
-
|
| 18 |
-
async def run_tool(self, name: str, inputs: dict, timeout: float = 30.0) -> dict:
|
| 19 |
-
self.calls.append((name, inputs))
|
| 20 |
-
if name in self.failures:
|
| 21 |
-
return {"success": False, "error": f"forced failure: {name}", "output": None}
|
| 22 |
-
return {"success": True, "output": {"ok": True}}
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
class VfsAtomicRollbackTests(unittest.IsolatedAsyncioTestCase):
|
| 26 |
-
def make_loop(self, executor: FakeExecutor) -> UnifiedAgentLoop:
|
| 27 |
-
loop = UnifiedAgentLoop.__new__(UnifiedAgentLoop)
|
| 28 |
-
loop.executor = executor
|
| 29 |
-
loop._write_snapshots = {}
|
| 30 |
-
return loop
|
| 31 |
-
|
| 32 |
-
async def test_rollback_restores_existing_and_deletes_new_files(self) -> None:
|
| 33 |
-
executor = FakeExecutor()
|
| 34 |
-
loop = self.make_loop(executor)
|
| 35 |
-
loop._write_snapshots = {"existing.txt": "before", "created.txt": None}
|
| 36 |
-
|
| 37 |
-
await loop._rollback_writes()
|
| 38 |
-
|
| 39 |
-
self.assertEqual(
|
| 40 |
-
[(name, inputs) for name, inputs in executor.calls],
|
| 41 |
-
[
|
| 42 |
-
("write_file", {"path": "existing.txt", "content": "before"}),
|
| 43 |
-
("delete_file", {"path": "created.txt"}),
|
| 44 |
-
],
|
| 45 |
-
)
|
| 46 |
-
self.assertEqual(loop._write_snapshots, {})
|
| 47 |
-
|
| 48 |
-
async def test_failed_restore_is_not_marked_clean(self) -> None:
|
| 49 |
-
executor = FakeExecutor({"write_file"})
|
| 50 |
-
loop = self.make_loop(executor)
|
| 51 |
-
loop._write_snapshots = {"existing.txt": "before", "created.txt": None}
|
| 52 |
-
|
| 53 |
-
with self.assertRaisesRegex(RuntimeError, "rollback incompleto"):
|
| 54 |
-
await loop._rollback_writes()
|
| 55 |
-
|
| 56 |
-
self.assertEqual(loop._write_snapshots, {"existing.txt": "before"})
|
| 57 |
-
self.assertEqual(executor.calls[1][0], "delete_file")
|
| 58 |
-
|
| 59 |
-
async def test_delete_file_respects_fs_jail_and_is_idempotent(self) -> None:
|
| 60 |
-
with tempfile.TemporaryDirectory() as root:
|
| 61 |
-
with patch.dict("os.environ", {"FS_TOOL_ROOT": root}, clear=False):
|
| 62 |
-
target = Path(root) / "created.txt"
|
| 63 |
-
target.write_text("created", encoding="utf-8")
|
| 64 |
-
deleted = await _delete_file("created.txt")
|
| 65 |
-
repeated = await _delete_file("created.txt")
|
| 66 |
-
outside = await _delete_file("../outside.txt")
|
| 67 |
-
|
| 68 |
-
self.assertTrue(deleted["ok"])
|
| 69 |
-
self.assertTrue(deleted["deleted"])
|
| 70 |
-
self.assertTrue(repeated["ok"])
|
| 71 |
-
self.assertFalse(repeated["deleted"])
|
| 72 |
-
self.assertFalse(outside["ok"])
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
if __name__ == "__main__":
|
| 76 |
-
unittest.main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tools/_shell_safety.py
CHANGED
|
@@ -7,7 +7,7 @@ Algoritmo:
|
|
| 7 |
1. Blocca metacaratteri shell (previene bypass allowlist)
|
| 8 |
2. shlex.split() — tokenizzazione sicura, rileva quoting anomalo
|
| 9 |
3. frozenset argv[0] — allowlist letterale, non regex di prefisso
|
| 10 |
-
4. Regole per git (subcmd)
|
| 11 |
5. create_subprocess_exec / subprocess.run con lista argv — NO shell=True
|
| 12 |
"""
|
| 13 |
from __future__ import annotations
|
|
@@ -17,12 +17,11 @@ from typing import Optional
|
|
| 17 |
_METACHAR_RE = re.compile(r'[;&|`<>\n\r]|\$[\(\{]')
|
| 18 |
_ALLOWED: frozenset = frozenset({
|
| 19 |
"ls", "cat", "echo", "pwd", "whoami", "date", "uname",
|
| 20 |
-
"python3", "python", "node", "npm", "pnpm",
|
| 21 |
"grep", "find", "head", "tail", "wc", "sort", "uniq", "diff",
|
| 22 |
"mkdir", "touch", "cp", "mv", "chmod", "git", "curl", "wget",
|
| 23 |
})
|
| 24 |
_GIT_OK: frozenset = frozenset({"status", "log", "diff", "show", "branch", "remote"})
|
| 25 |
-
_PYTHON_MODULES_BLOCKED: frozenset = frozenset({"pip", "pip3", "ensurepip"})
|
| 26 |
|
| 27 |
|
| 28 |
def safe_shell_env() -> dict:
|
|
@@ -73,9 +72,6 @@ def validate_shell_command(command: str) -> Optional[str]:
|
|
| 73 |
if exe == "git":
|
| 74 |
if len(argv) < 2 or argv[1].lower() not in _GIT_OK:
|
| 75 |
return f"git sub-comando non permesso (ammessi: {', '.join(sorted(_GIT_OK))})"
|
| 76 |
-
elif exe in ("python", "python3") and len(argv) >= 3 and argv[1] == "-m":
|
| 77 |
-
if argv[2].lower() in _PYTHON_MODULES_BLOCKED:
|
| 78 |
-
return f"modulo Python non permesso: '{argv[2]}'"
|
| 79 |
elif exe in ("curl", "wget"):
|
| 80 |
if not any(a.startswith("https://") for a in argv[1:]):
|
| 81 |
return f"{exe}: solo URL https://"
|
|
|
|
| 7 |
1. Blocca metacaratteri shell (previene bypass allowlist)
|
| 8 |
2. shlex.split() — tokenizzazione sicura, rileva quoting anomalo
|
| 9 |
3. frozenset argv[0] — allowlist letterale, non regex di prefisso
|
| 10 |
+
4. Regole per git (subcmd) e curl/wget (solo https://)
|
| 11 |
5. create_subprocess_exec / subprocess.run con lista argv — NO shell=True
|
| 12 |
"""
|
| 13 |
from __future__ import annotations
|
|
|
|
| 17 |
_METACHAR_RE = re.compile(r'[;&|`<>\n\r]|\$[\(\{]')
|
| 18 |
_ALLOWED: frozenset = frozenset({
|
| 19 |
"ls", "cat", "echo", "pwd", "whoami", "date", "uname",
|
| 20 |
+
"python3", "python", "node", "npm", "pip3", "pip", "pnpm",
|
| 21 |
"grep", "find", "head", "tail", "wc", "sort", "uniq", "diff",
|
| 22 |
"mkdir", "touch", "cp", "mv", "chmod", "git", "curl", "wget",
|
| 23 |
})
|
| 24 |
_GIT_OK: frozenset = frozenset({"status", "log", "diff", "show", "branch", "remote"})
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
def safe_shell_env() -> dict:
|
|
|
|
| 72 |
if exe == "git":
|
| 73 |
if len(argv) < 2 or argv[1].lower() not in _GIT_OK:
|
| 74 |
return f"git sub-comando non permesso (ammessi: {', '.join(sorted(_GIT_OK))})"
|
|
|
|
|
|
|
|
|
|
| 75 |
elif exe in ("curl", "wget"):
|
| 76 |
if not any(a.startswith("https://") for a in argv[1:]):
|
| 77 |
return f"{exe}: solo URL https://"
|
tools/registry.py
CHANGED
|
@@ -92,7 +92,7 @@ from tools._shell_safety import (
|
|
| 92 |
)
|
| 93 |
import re as _re_registry
|
| 94 |
_REGISTRY_SAFE_CMD_RE = _re_registry.compile(
|
| 95 |
-
r'^(ls|cat|echo|pwd|whoami|date|uname|python3?|node|npm|pnpm|'
|
| 96 |
r'grep|find|head|tail|wc|sort|uniq|diff|mkdir|touch|cp|mv|chmod|'
|
| 97 |
r'git\s+(status|log|diff|show)|curl\s+https?://|wget\s+https?://)(\s|$)',
|
| 98 |
_re_registry.IGNORECASE,
|
|
@@ -685,24 +685,6 @@ async def _read_file(path: str, encoding: str = "utf-8") -> dict:
|
|
| 685 |
return {"ok": False, "error": str(exc)}
|
| 686 |
|
| 687 |
|
| 688 |
-
async def _delete_file(path: str) -> dict:
|
| 689 |
-
"""Rimuove un file dalla jail VFS in modo idempotente."""
|
| 690 |
-
try:
|
| 691 |
-
_p, _err = _safe_fs_path(path)
|
| 692 |
-
if _err:
|
| 693 |
-
return {"ok": False, "error": _err}
|
| 694 |
-
if not _p.exists():
|
| 695 |
-
return {"ok": True, "path": path, "deleted": False}
|
| 696 |
-
if not _p.is_file():
|
| 697 |
-
return {"ok": False, "error": f"Non è un file: {path}"}
|
| 698 |
-
_p.unlink()
|
| 699 |
-
return {"ok": True, "path": path, "deleted": True}
|
| 700 |
-
except PermissionError:
|
| 701 |
-
return {"ok": False, "error": f"Accesso negato: {path}"}
|
| 702 |
-
except Exception as exc:
|
| 703 |
-
return {"ok": False, "error": str(exc)}
|
| 704 |
-
|
| 705 |
-
|
| 706 |
async def _write_file(path: str, content: str, encoding: str = "utf-8") -> dict:
|
| 707 |
"""S666/GAP-2/SEC-FS-JAIL: Scrive/sovrascrive un file nel filesystem del backend, confinato a _fs_jail_root().
|
| 708 |
Manus Gap fix: read-back verifica completezza scrittura.
|
|
@@ -2317,16 +2299,6 @@ TOOL_REGISTRY: dict[str, dict] = {
|
|
| 2317 |
"fallbacks": [],
|
| 2318 |
"_fn": _read_file,
|
| 2319 |
},
|
| 2320 |
-
"delete_file": {
|
| 2321 |
-
"name": "delete_file",
|
| 2322 |
-
"goal": "Rimuove un file dal filesystem del backend",
|
| 2323 |
-
"description": "Rimuove un file locale confinato alla jail VFS; idempotente se il file è già assente.",
|
| 2324 |
-
"required_inputs": ["path"],
|
| 2325 |
-
"optional_inputs": {},
|
| 2326 |
-
"risk_level": "medium",
|
| 2327 |
-
"fallbacks": [],
|
| 2328 |
-
"_fn": _delete_file,
|
| 2329 |
-
},
|
| 2330 |
"write_file": {
|
| 2331 |
"name": "write_file",
|
| 2332 |
"goal": "Scrive o sovrascrive un file nel filesystem del backend",
|
|
|
|
| 92 |
)
|
| 93 |
import re as _re_registry
|
| 94 |
_REGISTRY_SAFE_CMD_RE = _re_registry.compile(
|
| 95 |
+
r'^(ls|cat|echo|pwd|whoami|date|uname|python3?|node|npm|pip[3]?|pnpm|'
|
| 96 |
r'grep|find|head|tail|wc|sort|uniq|diff|mkdir|touch|cp|mv|chmod|'
|
| 97 |
r'git\s+(status|log|diff|show)|curl\s+https?://|wget\s+https?://)(\s|$)',
|
| 98 |
_re_registry.IGNORECASE,
|
|
|
|
| 685 |
return {"ok": False, "error": str(exc)}
|
| 686 |
|
| 687 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 688 |
async def _write_file(path: str, content: str, encoding: str = "utf-8") -> dict:
|
| 689 |
"""S666/GAP-2/SEC-FS-JAIL: Scrive/sovrascrive un file nel filesystem del backend, confinato a _fs_jail_root().
|
| 690 |
Manus Gap fix: read-back verifica completezza scrittura.
|
|
|
|
| 2299 |
"fallbacks": [],
|
| 2300 |
"_fn": _read_file,
|
| 2301 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2302 |
"write_file": {
|
| 2303 |
"name": "write_file",
|
| 2304 |
"goal": "Scrive o sovrascrive un file nel filesystem del backend",
|