Terminal / api /files.py
Baida—-
fix: remove escaped quotes SyntaxError in api/files.py
1348f58 verified
Raw
History Blame Contribute Delete
16.4 kB
"""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=<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',
},
)
@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