Spaces:
Configuration error
Configuration error
| """backend/api/files.py — Virtual File System CRUD (S354). | |
| GAP-VFS-FIX (P37): | |
| - _lint_and_update_manifest() ora riceve content_updated_at (updated_at del record | |
| appena salvato). Passato a lint_and_store() per confronto ottimistico. | |
| - PUT /api/files/{id}: accetta optional "expected_updated_at" nel body. | |
| Se presente e il valore non corrisponde all'updated_at corrente → HTTP 409 Conflict. | |
| Backward compatible: assente = comportamento legacy (last-write-wins). | |
| - Tutti i writer (POST, PUT, _write_file_internal) propagano updated_at al task lint. | |
| """ | |
| import uuid, time, asyncio | |
| from typing import Optional | |
| from fastapi import APIRouter, Body, HTTPException | |
| from .state import sb | |
| router = APIRouter() | |
| # ─── S364: fire-and-forget lint + manifest helper ──────────────────────────── | |
| async def _lint_and_update_manifest( | |
| file_id: str, | |
| content: str, | |
| language: str, | |
| path: str, | |
| conversation_id: str, | |
| content_updated_at: int = 0, | |
| ) -> None: | |
| """S364: background lint + manifest update. Never raises, never blocks VFS write. | |
| GAP-VFS-FIX: content_updated_at è l'updated_at del record al momento della | |
| scrittura. Passato a lint_and_store() per evitare scrittura di risultati stale | |
| se il file è stato aggiornato di nuovo nel frattempo. | |
| """ | |
| try: | |
| from .linter import lint_and_store | |
| await lint_and_store( | |
| file_id, content, language, path, conversation_id, | |
| content_updated_at=content_updated_at, | |
| ) | |
| except Exception: | |
| pass # S364: silent failure always | |
| try: | |
| from .project_manifest import update_manifest | |
| await update_manifest(conversation_id, path, content, language) | |
| except Exception: | |
| pass # S364: silent failure always | |
| # ─── P20-MCP: _write_file_internal — usata da mcp.py file_write tool ───────── | |
| _LANG_BY_EXT: dict[str, str] = { | |
| ".py": "python", ".ts": "typescript", ".tsx": "typescript", | |
| ".js": "javascript", ".jsx": "javascript", ".html": "html", | |
| ".css": "css", ".json": "json", ".md": "markdown", | |
| ".yaml": "yaml", ".yml": "yaml", ".sh": "bash", | |
| ".txt": "text", ".env": "env", | |
| } | |
| def _detect_lang(path: str) -> str: | |
| """Infer language from file extension for VFS metadata.""" | |
| import os as _os | |
| _, ext = _os.path.splitext(path or "") | |
| return _LANG_BY_EXT.get(ext.lower(), "text") | |
| async def _write_file_internal(path: str, content: str, | |
| conversation_id: str = "") -> dict: | |
| """P20-MCP: Crea/aggiorna un file VFS per path. | |
| Usata dal MCP file_write tool e altri consumer interni. | |
| Se Supabase non disponibile → ritorna payload senza persist (mai lancia eccezioni). | |
| Avvia lint + manifest update in background (S364 pattern). | |
| GAP-VFS-FIX: propaga l'updated_at effettivo del record salvato al task lint, | |
| così lint_and_store() può scartare il risultato se il file è cambiato di nuovo. | |
| """ | |
| body: dict = { | |
| "id": str(uuid.uuid4()), | |
| "path": path, | |
| "content": content, | |
| "language": _detect_lang(path), | |
| "conversation_id": conversation_id or "", | |
| "updated_at": int(time.time() * 1000), | |
| "created_at": int(time.time() * 1000), | |
| } | |
| try: | |
| data = sb().table("vfs_files").upsert(body).execute() | |
| saved = data.data[0] if data.data else body | |
| _fid = saved.get("id", body["id"]) | |
| # GAP-VFS-FIX: usa l'updated_at del record effettivamente salvato | |
| _saved_at = saved.get("updated_at", body["updated_at"]) | |
| if content and _fid: | |
| asyncio.create_task(_lint_and_update_manifest( | |
| _fid, content, body["language"], path, body["conversation_id"], | |
| content_updated_at=_saved_at, | |
| )) | |
| return saved | |
| except Exception as exc: | |
| import logging as _lg | |
| _lg.getLogger("files").warning("_write_file_internal %s: %s", path, exc) | |
| return body # in-memory fallback | |
| async def list_files(conversation_id: Optional[str] = None): | |
| try: | |
| q = sb().table('vfs_files').select('id, path, language, conversation_id, updated_at') | |
| if conversation_id: | |
| q = q.eq('conversation_id', conversation_id) | |
| data = q.order('path').execute() | |
| return {'files': data.data} | |
| except HTTPException: | |
| raise | |
| except Exception as exc: | |
| # S750-GAP-J: Supabase irraggiungibile → lista vuota invece di 500 | |
| import logging; logging.getLogger("files").warning("list_files: %s", exc) | |
| return {'files': [], '_error': str(exc)[:120]} | |
| async def get_file(file_id: str): | |
| try: | |
| data = sb().table('vfs_files').select('*').eq('id', file_id).single().execute() | |
| if not data.data: | |
| raise HTTPException(status_code=404, detail='File not found') | |
| return {'file': data.data} | |
| except HTTPException: | |
| raise | |
| except Exception: | |
| raise HTTPException(status_code=404, detail='File not found') | |
| import os | |
| # S42: Soglia per offloading su HF Storage (512 KB) | |
| VFS_OFFLOAD_THRESHOLD_BYTES = 512 * 1024 | |
| async def _offload_to_hf(content: str, filename: str) -> str: | |
| """Carica il contenuto su HF Storage e restituisce l'URL.""" | |
| from .hf_storage import hf_append_record | |
| record = { | |
| "filename": filename, | |
| "content_preview": content[:1000], | |
| "full_content": content, | |
| "offloaded_at": int(time.time() * 1000) | |
| } | |
| # Usiamo hf_append_record per salvare il file nel dataset | |
| success = await hf_append_record("offloaded_files.jsonl", record) | |
| if success: | |
| # Nota: In produzione qui genereremmo un URL diretto R2 o HF | |
| return f"https://huggingface.co/datasets/{os.getenv('HF_DATASET_REPO', 'Arjanit98/agent-memory')}/raw/main/offloaded_files.jsonl" | |
| return "" | |
| async def save_file(body: dict = Body(...)): | |
| _content = body.get('content', '') or '' | |
| _path = body.get('path', 'unnamed') | |
| # S42: Offloading logic | |
| if len(_content.encode('utf-8')) > VFS_OFFLOAD_THRESHOLD_BYTES: | |
| body['is_offloaded'] = True | |
| body['original_size'] = len(_content) | |
| # Per ora simuliamo l'offload salvando un riferimento | |
| # In un'implementazione reale, caricheremmo su R2/S3 qui | |
| body['download_url'] = f'/api/files/raw/{_path}' | |
| if 'id' not in body: | |
| body['id'] = str(uuid.uuid4()) | |
| if 'updated_at' not in body: | |
| body['updated_at'] = int(time.time() * 1000) | |
| if 'created_at' not in body: | |
| body['created_at'] = body['updated_at'] | |
| try: | |
| data = sb().table('vfs_files').upsert(body).execute() | |
| saved = data.data[0] if data.data else body | |
| except HTTPException: | |
| raise | |
| except Exception as exc: | |
| # S750-GAP-J: Supabase irraggiungibile → restituisce payload originale | |
| import logging; logging.getLogger("files").warning("save_file: %s", exc) | |
| saved = body | |
| # S364: fire-and-forget lint + manifest update (never blocks response) | |
| _content = body.get('content', '') or '' | |
| _lang = body.get('language', '') or '' | |
| _path = body.get('path', '') or '' | |
| _file_id = saved.get('id', body.get('id', '')) | |
| _conv_id = body.get('conversation_id', '') or '' | |
| # GAP-VFS-FIX: usa updated_at effettivo del record salvato | |
| _saved_at = saved.get('updated_at', body.get('updated_at', 0)) | |
| if _content and _file_id: | |
| asyncio.create_task(_lint_and_update_manifest( | |
| _file_id, _content, _lang, _path, _conv_id, | |
| content_updated_at=_saved_at, | |
| )) | |
| return {'file': saved} | |
| async def update_file(file_id: str, body: dict = Body(...)): | |
| """[S195] Aggiorna il contenuto di un file VFS esistente. | |
| GAP-VFS-FIX: supporto optimistic locking via expected_updated_at. | |
| Se body contiene "expected_updated_at" (BIGINT ms), la UPDATE usa | |
| WHERE id=? AND updated_at=expected_updated_at. | |
| 0 righe aggiornate → 409 Conflict con {error, current_updated_at}. | |
| Assente/None → comportamento legacy (last-write-wins). | |
| """ | |
| # Estrai ed eventualmente rimuovi il campo di controllo prima dell'update | |
| expected_updated_at: int | None = body.pop('expected_updated_at', None) | |
| body.pop('id', None) | |
| body['updated_at'] = int(time.time() * 1000) | |
| try: | |
| q = sb().table('vfs_files').update(body).eq('id', file_id) | |
| if expected_updated_at is not None: | |
| # GAP-VFS-FIX: confronto ottimistico — filtra per updated_at atteso | |
| q = q.eq('updated_at', int(expected_updated_at)) | |
| data = q.execute() | |
| if not data.data: | |
| if expected_updated_at is not None: | |
| # Nessuna riga aggiornata + expected_updated_at fornito → conflitto | |
| # Recupera lo stato corrente per restituirlo al client | |
| try: | |
| current = ( | |
| sb().table('vfs_files') | |
| .select('id, updated_at, version') | |
| .eq('id', file_id) | |
| .maybe_single() | |
| .execute() | |
| ) | |
| current_data = current.data if current else None | |
| except Exception: | |
| current_data = None | |
| raise HTTPException( | |
| status_code=409, | |
| detail={ | |
| 'error': 'conflict', | |
| 'message': ( | |
| 'Il file è stato modificato da un altro writer. ' | |
| 'Ricarica e riapplica le modifiche.' | |
| ), | |
| 'expected_updated_at': expected_updated_at, | |
| 'current_updated_at': current_data.get('updated_at') if current_data else None, | |
| 'current_version': current_data.get('version') if current_data else None, | |
| 'file_id': file_id, | |
| }, | |
| ) | |
| else: | |
| # Nessun expected_updated_at → tratta come not found (comportamento originale) | |
| raise HTTPException(status_code=404, detail='File not found') | |
| saved = data.data[0] | |
| # S364: fire-and-forget lint + manifest update (never blocks response) | |
| _content = body.get('content', '') or '' | |
| _lang = body.get('language', '') or '' | |
| _path = body.get('path', '') or saved.get('path', '') | |
| _conv_id = body.get('conversation_id', '') or saved.get('conversation_id', '') or '' | |
| # GAP-VFS-FIX: updated_at dal record effettivamente salvato | |
| _saved_at = saved.get('updated_at', body['updated_at']) | |
| if _content and file_id: | |
| asyncio.create_task(_lint_and_update_manifest( | |
| file_id, _content, _lang, _path, _conv_id, | |
| content_updated_at=_saved_at, | |
| )) | |
| return {'file': saved} | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def delete_file(file_id: str): | |
| try: | |
| sb().table('vfs_files').delete().eq('id', file_id).execute() | |
| except HTTPException: | |
| raise | |
| except Exception as exc: | |
| import logging; logging.getLogger("files").warning("delete_file %s: %s", file_id, exc) | |
| return {'deleted': file_id} | |
| # ─── S363-Blueprint: Instant Project Export ────────────────────────────────── | |
| async def export_project(conversation_id: str): | |
| """ | |
| Package all VFS files for a conversation into a ready-to-use ZIP. | |
| S363-Blueprint: Final Artifact — sempre consegna un link funzionante. | |
| Usage: GET /api/project/export?conversation_id=<uuid> | |
| Returns: application/zip download | |
| """ | |
| import io | |
| import zipfile | |
| from fastapi.responses import StreamingResponse | |
| try: | |
| data = ( | |
| sb() | |
| .table('vfs_files') | |
| .select('path, content, language') | |
| .eq('conversation_id', conversation_id) | |
| .order('path') | |
| .execute() | |
| ) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f'VFS query failed: {e}') | |
| files = data.data or [] | |
| if not files: | |
| raise HTTPException(status_code=404, detail='No files found for this conversation') | |
| buf = io.BytesIO() | |
| with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: | |
| for f in files: | |
| # Normalize path — strip leading slash, default to 'file.txt' | |
| rel_path = (f.get('path') or 'unnamed_file').lstrip('/') | |
| content = f.get('content') or '' | |
| zf.writestr(rel_path, content) | |
| # Add a minimal README | |
| readme = ( | |
| f"# Project Export\n\n" | |
| f"Conversation: {conversation_id}\n" | |
| f"Files: {len(files)}\n\n" | |
| "Generated by Agente AI Pro.\n" | |
| ) | |
| zf.writestr('README.md', readme) | |
| buf.seek(0) | |
| short_id = conversation_id[:8] if len(conversation_id) >= 8 else conversation_id | |
| return StreamingResponse( | |
| buf, | |
| media_type='application/zip', | |
| headers={ | |
| 'Content-Disposition': f'attachment; filename="project_{short_id}.zip"', | |
| 'Cache-Control': 'no-cache', | |
| }, | |
| ) | |
| async def export_manifest(conversation_id: str): | |
| """ | |
| Return list of exportable files and sizes for a conversation. | |
| Used by frontend to show 'Download ZIP (N files)' button. | |
| """ | |
| try: | |
| data = ( | |
| sb() | |
| .table('vfs_files') | |
| .select('path, language, updated_at') | |
| .eq('conversation_id', conversation_id) | |
| .order('path') | |
| .execute() | |
| ) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| files = data.data or [] | |
| return { | |
| 'conversation_id': conversation_id, | |
| 'file_count': len(files), | |
| 'files': [{'path': f.get('path', ''), 'language': f.get('language', '')} for f in files], | |
| 'download_url': f'/api/project/export?conversation_id={conversation_id}', | |
| } | |
| # ── GAP-X6: scaffold_project REST endpoint ──────────────────────────────────── | |
| # Espone _scaffold_project per chiamate dirette dal frontend (NewProjectPanel.tsx). | |
| # Ritorna {success, files, framework, project_name, project_dir, output, created}. | |
| # Il frontend (vfsAsync.write) scrive ogni file nel VFS locale — nessun round-trip agent. | |
| async def scaffold_project_endpoint(body: dict = Body(...)): | |
| """ | |
| GAP-X6: genera boilerplate da template e restituisce i file al frontend. | |
| framework: react|nextjs|fastapi|flask|django|express | |
| project_name: slug del progetto (verrà sanitizzato) | |
| """ | |
| import re as _re_ep | |
| from tools.registry import _scaffold_project as _do_scaffold | |
| framework = str(body.get('framework', 'react')).strip().lower() | |
| project_name = str(body.get('project_name', 'my-project')).strip() | |
| # Sanity check: solo framework supportati | |
| _SUPPORTED = {'react', 'nextjs', 'fastapi', 'flask', 'django', 'express'} | |
| if framework not in _SUPPORTED: | |
| from fastapi import HTTPException | |
| raise HTTPException(status_code=400, detail=f"framework '{framework}' non supportato. Usa: {', '.join(sorted(_SUPPORTED))}") | |
| # Sanitizza project_name (identico a _scaffold_project internamente) | |
| _safe = _re_ep.sub(r'[^a-z0-9\-]', '-', project_name.lower())[:30] or 'my-project' | |
| if not _safe: | |
| from fastapi import HTTPException | |
| raise HTTPException(status_code=400, detail="project_name non valido") | |
| try: | |
| result = await _do_scaffold( | |
| framework=framework, | |
| project_name=_safe, | |
| target_dir='/tmp', | |
| ) | |
| except Exception as e: | |
| return {'success': False, 'error': str(e)[:300], 'files': {}} | |
| return result | |