"""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 @router.get('/api/files') 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]} @router.get('/api/files/{file_id}') 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 "" @router.post('/api/files') 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} @router.put('/api/files/{file_id}') 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)) @router.delete('/api/files/{file_id}') 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 ────────────────────────────────── @router.get('/api/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= 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', }, ) @router.get('/api/project/export/manifest') 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. @router.post('/api/scaffold_project') 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