Spaces:
Running
Running
sync: 204 file da Baida98/AI@843961e3 (2026-08-31 07:48 UTC) [deploy-all] (#154)
Browse files- sync: 204 file da Baida98/AI@843961e3 (2026-08-31 07:48 UTC) [deploy-all] (9f9d3dc314be6b399d4fe2afb217579f98304139)
- agents/executor.py +32 -8
- agents/unified_loop.py +60 -29
- api/background_tasks.py +53 -0
- api/job_queue.py +8 -11
- api/state.py +6 -1
- benchmark-extended.mjs +0 -0
- cors_policy.py +61 -0
- main.py +46 -7
- scripts/lib/report-engine.mjs +36 -0
- tests/test_agent_task_pruning.py +70 -0
- tests/test_background_tasks.py +57 -0
- tests/test_cors_policy.py +65 -0
- tests/test_executor_side_effect_retry.py +91 -0
- tests/test_model_watch_adapter.py +2 -2
- tests/test_telemetry_alert_lifecycle.py +45 -0
- tests/test_vfs_atomic_rollback.py +76 -0
- tools/registry.py +28 -0
agents/executor.py
CHANGED
|
@@ -292,15 +292,39 @@ 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 |
if self.memory:
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
|
| 305 |
except asyncio.TimeoutError:
|
| 306 |
# 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 |
+
|
| 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 |
+
try:
|
| 302 |
+
# S577→S600: inputs 100→500 — parity con altri handler
|
| 303 |
+
await self.memory.save_episode(
|
| 304 |
+
"tool",
|
| 305 |
+
f"{tool_name}: {str(inputs)[:500]}",
|
| 306 |
+
str(result)[:500],
|
| 307 |
+
True,
|
| 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
|
agents/unified_loop.py
CHANGED
|
@@ -214,37 +214,46 @@ 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 |
-
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":
|
| 229 |
"status": "streaming",
|
| 230 |
}))
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
|
|
|
|
|
|
| 235 |
try:
|
| 236 |
-
await asyncio.wait_for(
|
| 237 |
-
self.executor.run_tool(
|
| 238 |
timeout=10.0,
|
| 239 |
)
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,18 +1328,28 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 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
|
| 1323 |
if rn == "write_file" and _wf_path not in self._write_snapshots:
|
| 1324 |
-
|
| 1325 |
-
|
| 1326 |
-
|
| 1327 |
-
|
| 1328 |
-
|
| 1329 |
-
|
| 1330 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,6 +3576,12 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 3557 |
}
|
| 3558 |
|
| 3559 |
state.errors.append(error_text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3560 |
previous = state.state_machine.current
|
| 3561 |
if previous != AgentState.FAILED:
|
| 3562 |
try:
|
|
@@ -3714,6 +3739,12 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
|
|
| 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:
|
|
|
|
| 214 |
_logger.debug("[unified_loop] state callback silenced: %s", _state_callback_error)
|
| 215 |
|
| 216 |
async def _rollback_writes(self, on_step=None) -> None:
|
| 217 |
+
"""Restore all writes from this run or report an incomplete rollback.
|
| 218 |
+
|
| 219 |
+
A None snapshot means the file did not exist and must be removed.
|
| 220 |
+
Failed restores remain tracked so a supervisor can retry or block the run.
|
|
|
|
| 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": f"\u23ea Rollback di {len(self._write_snapshots)} file modificati...\n",
|
| 228 |
"status": "streaming",
|
| 229 |
}))
|
| 230 |
+
|
| 231 |
+
remaining: dict[str, str | None] = {}
|
| 232 |
+
rolled = 0
|
| 233 |
+
for path, original in list(self._write_snapshots.items()):
|
| 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 |
+
result = await asyncio.wait_for(
|
| 238 |
+
self.executor.run_tool(tool_name, inputs),
|
| 239 |
timeout=10.0,
|
| 240 |
)
|
| 241 |
+
payload = result.get("output") if isinstance(result, dict) else None
|
| 242 |
+
nested_failed = isinstance(payload, dict) and payload.get("ok") is False
|
| 243 |
+
if not isinstance(result, dict) or not result.get("success") or nested_failed:
|
| 244 |
+
error = (payload or {}).get("error") if isinstance(payload, dict) else None
|
| 245 |
+
raise RuntimeError(error or result.get("error", "rollback tool failed"))
|
| 246 |
+
rolled += 1
|
| 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 |
{"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; read errors are not "file absent".
|
| 1332 |
if rn == "write_file" and _wf_path not in self._write_snapshots:
|
| 1333 |
+
_snap_r = await asyncio.wait_for(
|
| 1334 |
+
self.executor.run_tool("read_file", {"path": _wf_path}),
|
| 1335 |
+
timeout=4.0,
|
| 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 |
}
|
| 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 |
|
| 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:
|
api/background_tasks.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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/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,18 +377,15 @@ async def start_job_queue_consumer() -> None:
|
|
| 377 |
return
|
| 378 |
|
| 379 |
# Load publisher su tutti gli Space
|
| 380 |
-
|
| 381 |
-
|
| 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 |
-
|
| 392 |
else:
|
| 393 |
_logger.info("[jq] SPACE_ROLE=%s — consumer non avviato (ruolo non worker)", _SPACE_ROLE)
|
| 394 |
|
|
|
|
| 355 |
continue
|
| 356 |
|
| 357 |
# Esegui in background — non blocca il loop consumer
|
| 358 |
+
from api.background_tasks import spawn_background_task
|
| 359 |
+
task_id = str(job.get("taskId") or uuid.uuid4())
|
| 360 |
+
spawn_background_task(
|
| 361 |
+
_execute_queued_job(job), name=f"job-queue-execution:{task_id}"
|
| 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 |
+
from api.background_tasks import spawn_background_task
|
| 381 |
+
spawn_background_task(_load_publisher_loop(), name="job-queue-load-publisher")
|
|
|
|
|
|
|
|
|
|
| 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 |
+
spawn_background_task(_hands_consumer_loop(), name="job-queue-hands-consumer")
|
| 389 |
else:
|
| 390 |
_logger.info("[jq] SPACE_ROLE=%s — consumer non avviato (ruolo non worker)", _SPACE_ROLE)
|
| 391 |
|
api/state.py
CHANGED
|
@@ -139,6 +139,11 @@ _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 |
|
| 143 |
# ── Telemetry & Health ────────────────────────────────────────────────────────
|
| 144 |
_ai_health_cache: dict = {"data": None, "at": 0.0}
|
|
@@ -244,7 +249,7 @@ def _prune_checkpoints() -> None:
|
|
| 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
|
| 248 |
and now - v.get('created_at', 0) > _AGENT_TASK_TTL_MS]
|
| 249 |
for k in expired:
|
| 250 |
_agent_tasks.pop(k, None)
|
|
|
|
| 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 |
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 _AGENT_TASK_TERMINAL_STATES
|
| 253 |
and now - v.get('created_at', 0) > _AGENT_TASK_TTL_MS]
|
| 254 |
for k in expired:
|
| 255 |
_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
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,6 +10,7 @@ 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 |
|
| 14 |
# Configurazione Logging
|
| 15 |
logging.basicConfig(
|
|
@@ -25,13 +26,14 @@ app = FastAPI(
|
|
| 25 |
version=RUNTIME_VERSION,
|
| 26 |
)
|
| 27 |
|
| 28 |
-
# CORS
|
|
|
|
| 29 |
app.add_middleware(
|
| 30 |
CORSMiddleware,
|
| 31 |
-
allow_origins=
|
| 32 |
-
allow_credentials=
|
| 33 |
-
allow_methods=["
|
| 34 |
-
allow_headers=["
|
| 35 |
)
|
| 36 |
|
| 37 |
# ── P17-F1: RLS Fix & Auto-Migration ──────────────────────────────────────────
|
|
@@ -244,6 +246,20 @@ async def run_cli_task(task_description: str):
|
|
| 244 |
sys.exit(1)
|
| 245 |
|
| 246 |
# ── Startup ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
@app.on_event("startup")
|
| 248 |
async def startup_event():
|
| 249 |
_logger.info("Server starting up...")
|
|
@@ -265,6 +281,14 @@ async def startup_event():
|
|
| 265 |
_logger.warning("⚠️ BOOT: public dashboard snapshot writer non ha persistito lo snapshot.")
|
| 266 |
except Exception as e:
|
| 267 |
_logger.warning(f"⚠️ BOOT: avvio public snapshot writer fallito (non bloccante): {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
try:
|
| 269 |
from api.providers import start_heartbeat
|
| 270 |
start_heartbeat()
|
|
@@ -273,9 +297,24 @@ async def startup_event():
|
|
| 273 |
_logger.warning(f"⚠️ BOOT: avvio provider heartbeat fallito (non bloccante): {e}")
|
| 274 |
if not any(arg in sys.argv for arg in ["--task", "-t"]):
|
| 275 |
try:
|
|
|
|
| 276 |
from api.job_queue import start_job_queue_consumer
|
| 277 |
-
|
| 278 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
|
| 280 |
# ── SPA Hosting ───────────────────────────────────────────────────────────────
|
| 281 |
_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 |
+
from cors_policy import allowed_origins
|
| 14 |
|
| 15 |
# Configurazione Logging
|
| 16 |
logging.basicConfig(
|
|
|
|
| 26 |
version=RUNTIME_VERSION,
|
| 27 |
)
|
| 28 |
|
| 29 |
+
# CORS: whitelist esatta e fail-closed. Il frontend usa bearer/internal headers,
|
| 30 |
+
# non cookie cross-origin; le credenziali browser restano quindi disabilitate.
|
| 31 |
app.add_middleware(
|
| 32 |
CORSMiddleware,
|
| 33 |
+
allow_origins=allowed_origins(),
|
| 34 |
+
allow_credentials=False,
|
| 35 |
+
allow_methods=["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
| 36 |
+
allow_headers=["Authorization", "Content-Type", "X-Internal-Token", "X-Requested-With"],
|
| 37 |
)
|
| 38 |
|
| 39 |
# ── P17-F1: RLS Fix & Auto-Migration ──────────────────────────────────────────
|
|
|
|
| 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=10.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...")
|
|
|
|
| 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 |
_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 |
+
spawn_background_task(
|
| 303 |
+
start_job_queue_consumer(), name="job-queue-consumer-supervisor"
|
| 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')
|
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
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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_background_tasks.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="x",
|
| 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"), "x")
|
| 182 |
return httpx.Response(200, json={"models": [
|
| 183 |
{"name": "models/gemini-3.6-flash"},
|
| 184 |
{"name": "models/gemini-3.5-flash"},
|
tests/test_telemetry_alert_lifecycle.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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/registry.py
CHANGED
|
@@ -685,6 +685,24 @@ async def _read_file(path: str, encoding: str = "utf-8") -> dict:
|
|
| 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,6 +2317,16 @@ TOOL_REGISTRY: dict[str, dict] = {
|
|
| 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",
|
|
|
|
| 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 |
"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",
|