diff --git a/.env.example b/.env.example index b0a9cd9..984d0bf 100644 --- a/.env.example +++ b/.env.example @@ -27,8 +27,25 @@ OLLAMA_PORT=11434 OLLAMA_BASE_URL=http://localhost:11434 # Modelos recomendados no PRD (Qwen 2.5 / Llama 3.1 para LLM, bge-m3 para embeddings) OLLAMA_LLM_MODEL=qwen2.5:1.5b +OLLAMA_NUM_PREDICT=1024 +OLLAMA_NUM_CTX=8192 +OLLAMA_FILE_NUM_PREDICT=512 +OLLAMA_SYNTHESIS_NUM_PREDICT=3072 OLLAMA_EMBEDDING_MODEL=bge-m3 OLLAMA_KEEP_ALIVE=24h +# URL do serviço RepoAnalyzer quando o backend roda em Docker Compose. +REPO_ANALYZER_URL=http://ai-service:8000 +# Limite de arquivos priorizados nos perfis do RepoAnalyzer (Complete usa MAX_FILES). +ANALYZER_QUICK_FILES=8 +ANALYZER_BALANCED_FILES=80 +# Limite rígido de arquivos elegíveis inventariados pelo RepoAnalyzer. +MAX_FILES=1000 +ANALYZER_MAX_ACTIVE_RUNS=2 +ANALYZER_MAX_QUEUED_RUNS=6 +ANALYZER_WORKSPACE_QUOTA_MB=2048 +MAX_REPO_SIZE_MB=300 +ANALYZER_CLONE_MEMORY_MB=512 +ANALYZER_CLONE_TIMEOUT_SECONDS=300 # --- Documentos enviados (S1-19/S1-22) --- # Limite de tamanho por arquivo, aplicado no backend e informado ao cliente diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7884e5a..ee03135 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,7 @@ jobs: - run: npm run test:seed working-directory: backend - name: Test archive cascade on PostgreSQL - run: node --import tsx --test src/modules/projects/projects.archive.db.test.ts src/modules/projects/hierarchy-archive.db.test.ts src/modules/projects/projects.backlog-tree.db.test.ts src/database/migration-010.db.test.ts src/database/migration-011.db.test.ts src/database/migration-012.db.test.ts src/modules/documents/documents.repository.db.test.ts src/modules/chat/chat.repository.db.test.ts src/modules/backlog-search/backlog-search.db.test.ts src/modules/decisions/decisions.db.test.ts src/database/migration-013.db.test.ts src/modules/priority-fixes.db.test.ts src/modules/documents/documents.ingestion.db.test.ts src/modules/projects/project-access.db.test.ts + run: node --import tsx --test src/modules/projects/projects.archive.db.test.ts src/modules/projects/hierarchy-archive.db.test.ts src/modules/projects/projects.backlog-tree.db.test.ts src/database/migration-010.db.test.ts src/database/migration-011.db.test.ts src/database/migration-012.db.test.ts src/modules/documents/documents.repository.db.test.ts src/modules/chat/chat.repository.db.test.ts src/modules/backlog-search/backlog-search.db.test.ts src/modules/decisions/decisions.db.test.ts src/database/migration-013.db.test.ts src/modules/priority-fixes.db.test.ts src/modules/documents/documents.ingestion.db.test.ts src/modules/projects/project-access.db.test.ts src/modules/repo-analyses/repo-analyses.dispatch.db.test.ts working-directory: backend env: ARCHIVE_TEST_DATABASE_URL: postgresql://seed_test:seed_test_only@localhost:5432/sinapse_seed_test diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 19b875c..efa1d9e 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -45,6 +45,9 @@ jobs: POSTGRES_USER: e2e POSTGRES_PASSWORD: e2e_only POSTGRES_DB: sinapse_e2e_test + E2E_DATABASE_URL: postgresql://e2e:e2e_only@localhost:5432/sinapse_e2e_test + AI_SERVICE_URL: http://127.0.0.1:9 + REPO_ANALYZER_URL: http://127.0.0.1:9 E2E_API_URL: http://localhost:3001/api/v1 E2E_APP_URL: http://localhost:5173 E2E_CHROME_PATH: /usr/bin/google-chrome @@ -65,7 +68,7 @@ jobs: run: | export DOCUMENT_STORAGE_DIR="$RUNNER_TEMP/documents" mkdir -p "$DOCUMENT_STORAGE_DIR" - (cd backend && nohup npx tsx src/index.ts > "$RUNNER_TEMP/backend.log" 2>&1 &) + (cd backend && nohup env NODE_ENV=test node --import tsx ../e2e/server.mts > "$RUNNER_TEMP/backend.log" 2>&1 &) (cd frontend && nohup npx vite --port 5173 --strictPort > "$RUNNER_TEMP/frontend.log" 2>&1 &) for i in $(seq 1 30); do curl -fs http://localhost:3001/health > /dev/null && curl -fs http://localhost:5173 > /dev/null && exit 0 diff --git a/ai-service/Dockerfile b/ai-service/Dockerfile index 449f246..53f16ba 100644 --- a/ai-service/Dockerfile +++ b/ai-service/Dockerfile @@ -2,9 +2,10 @@ FROM python:3.11-slim WORKDIR /app -# Install system dependencies +# Git is required by the repository analyzer to clone public GitHub projects. RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ + git \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . diff --git a/ai-service/analyzer/config.py b/ai-service/analyzer/config.py index a8f3dc8..466438d 100644 --- a/ai-service/analyzer/config.py +++ b/ai-service/analyzer/config.py @@ -15,13 +15,24 @@ class AnalyzerSettings: max_file_size_kb: int = int(os.getenv("MAX_FILE_SIZE_KB", "500")) max_chunk_chars: int = int(os.getenv("MAX_CHUNK_CHARS", "10000")) max_files: int = int(os.getenv("MAX_FILES", "1000")) + quick_profile_files: int = int(os.getenv("ANALYZER_QUICK_FILES", "8")) + balanced_profile_files: int = int(os.getenv("ANALYZER_BALANCED_FILES", "80")) workspace_dir: Path = Path(os.getenv("WORKSPACE_DIR", "workspace_analyzer")) request_timeout_seconds: int = int(os.getenv("REQUEST_TIMEOUT_SECONDS", "300")) ollama_keep_alive: str = os.getenv("OLLAMA_KEEP_ALIVE", "5m") + ollama_num_predict: int = max(128, int(os.getenv("OLLAMA_NUM_PREDICT", "1024"))) + ollama_num_ctx: int = max(2048, int(os.getenv("OLLAMA_NUM_CTX", "8192"))) + ollama_file_num_predict: int = max(128, int(os.getenv("OLLAMA_FILE_NUM_PREDICT", "512"))) + ollama_synthesis_num_predict: int = max(128, int(os.getenv("OLLAMA_SYNTHESIS_NUM_PREDICT", "3072"))) ollama_think: bool = False ollama_max_retries: int = int(os.getenv("OLLAMA_MAX_RETRIES", "2")) ollama_retry_backoff_seconds: float = float(os.getenv("OLLAMA_RETRY_BACKOFF_SECONDS", "3")) max_synthesis_chars: int = int(os.getenv("MAX_SYNTHESIS_CHARS", "60000")) + max_active_runs: int = max(1, int(os.getenv("ANALYZER_MAX_ACTIVE_RUNS", "2"))) + max_queued_runs: int = max(0, int(os.getenv("ANALYZER_MAX_QUEUED_RUNS", "6"))) + workspace_quota_mb: int = max(1, int(os.getenv("ANALYZER_WORKSPACE_QUOTA_MB", "2048"))) + clone_timeout_seconds: int = max(1, int(os.getenv("ANALYZER_CLONE_TIMEOUT_SECONDS", "300"))) + clone_memory_mb: int = max(128, int(os.getenv("ANALYZER_CLONE_MEMORY_MB", "512"))) ollama_concurrency: int = max(1, int(os.getenv("OLLAMA_CONCURRENCY", "1"))) diff --git a/ai-service/analyzer/limited_git.py b/ai-service/analyzer/limited_git.py new file mode 100644 index 0000000..1b79e41 --- /dev/null +++ b/ai-service/analyzer/limited_git.py @@ -0,0 +1,11 @@ +"""Exec git with per-process bounds without unsafe preexec_fn in threaded workers.""" +import os +import resource +import sys + +if __name__ == "__main__": + disk_mb, memory_mb, cpu_seconds = map(int, sys.argv[1:4]) + resource.setrlimit(resource.RLIMIT_FSIZE, (disk_mb * 1024**2,) * 2) + resource.setrlimit(resource.RLIMIT_AS, (memory_mb * 1024**2,) * 2) + resource.setrlimit(resource.RLIMIT_CPU, (cpu_seconds,) * 2) + os.execvp("git", ["git", "-c", "core.hooksPath=/dev/null", *sys.argv[4:]]) diff --git a/ai-service/analyzer/models.py b/ai-service/analyzer/models.py index 723a1d7..ad494bf 100644 --- a/ai-service/analyzer/models.py +++ b/ai-service/analyzer/models.py @@ -1,4 +1,5 @@ from dataclasses import dataclass, field +import threading from typing import Any @@ -20,6 +21,7 @@ class RunState: run_id: str url: str status: str = "created" + profile: str = "quick" stage: str = "" message: str = "" error: str = "" @@ -32,7 +34,16 @@ class RunState: files_total: int = 0 files_processed: int = 0 files_ignored: int = 0 + files_candidates: int = 0 + files_skipped_by_scope: int = 0 language_counts: dict[str, int] = field(default_factory=dict) current_files: list[str] = field(default_factory=list) + selected_paths: list[str] = field(default_factory=list) + completed_summaries: dict[str, str] = field(default_factory=dict) + partial_chunk_summaries: dict[str, list[str]] = field(default_factory=dict) + project_summary: str = "" + pause_event: Any = field(default_factory=threading.Event, repr=False) + cancel_event: Any = field(default_factory=threading.Event, repr=False) + worker_thread: Any = field(default=None, repr=False) stats: dict[str, Any] = field(default_factory=dict) diff --git a/ai-service/analyzer/ollama_client.py b/ai-service/analyzer/ollama_client.py index 157cf1c..53f67f2 100644 --- a/ai-service/analyzer/ollama_client.py +++ b/ai-service/analyzer/ollama_client.py @@ -1,5 +1,7 @@ import re import time +import json +from collections.abc import Callable import httpx @@ -30,6 +32,8 @@ def __init__( think: bool = False, max_retries: int = 2, retry_backoff_seconds: float = 3.0, + num_predict: int = 1024, + num_ctx: int = 8192, ): self.base_url = base_url.rstrip("/") self.model = model @@ -38,40 +42,85 @@ def __init__( self.think = False self.max_retries = max(0, max_retries) self.retry_backoff_seconds = retry_backoff_seconds + self.num_predict = max(128, num_predict) + self.num_ctx = max(2048, num_ctx) self._client = httpx.Client(timeout=timeout) def close(self) -> None: self._client.close() - def chat(self, system: str, user: str, temperature: float = 0.1) -> str: + def chat( + self, + system: str, + user: str, + temperature: float = 0.1, + should_cancel: Callable[[], None] | None = None, + num_predict: int | None = None, + accept_truncated: bool = False, + ) -> str: if not system.strip().startswith(("/nothink", "/no_think")): system = f"/nothink\n{system}" + output_limit = max(128, num_predict or self.num_predict) payload = { "model": self.model, "messages": [ {"role": "system", "content": system}, {"role": "user", "content": user}, ], - "stream": False, + "stream": True, "keep_alive": self.keep_alive, "think": False, "options": { - "temperature": temperature + "temperature": temperature, + "num_predict": output_limit, + "num_ctx": self.num_ctx, } } last_error: Exception | None = None - for attempt in range(self.max_retries + 1): + output_limit_retries = 0 + http_retries = 0 + while True: try: - response = self._client.post(f"{self.base_url}/api/chat", json=payload) - response.raise_for_status() - data = response.json() + content_parts: list[str] = [] + done_reason = "" + with self._client.stream("POST", f"{self.base_url}/api/chat", json=payload) as response: + response.raise_for_status() + for line in response.iter_lines(): + if should_cancel: + should_cancel() + if not line: + continue + data = json.loads(line) + content = data.get("message", {}).get("content", "") + if content: + content_parts.append(content) + if data.get("done"): + done_reason = data.get("done_reason", "") + break + if done_reason == "length": + if accept_truncated: + data = {"message": {"content": "".join(content_parts) + + "\n\n[Análise resumida limitada pelo teto de tokens; trate este trecho como parcial.]"}} + break + if output_limit_retries < 4 and output_limit < 8192: + output_limit = min(8192, output_limit * 2) + output_limit_retries += 1 + http_retries = 0 + payload["options"]["num_predict"] = output_limit + continue + raise OllamaError( + f"A resposta do modelo atingiu o limite de {output_limit} tokens. " + "Aumente OLLAMA_FILE_NUM_PREDICT ou OLLAMA_SYNTHESIS_NUM_PREDICT." + ) + data = {"message": {"content": "".join(content_parts)}} break except httpx.HTTPError as exc: last_error = exc - if attempt < self.max_retries: - time.sleep(self.retry_backoff_seconds * (attempt + 1)) + if http_retries < self.max_retries: + http_retries += 1 + time.sleep(self.retry_backoff_seconds * http_retries) continue raise OllamaError( f"Não foi possível acessar o Ollama em {self.base_url} " diff --git a/ai-service/analyzer/pipeline.py b/ai-service/analyzer/pipeline.py index a03ce75..981168e 100644 --- a/ai-service/analyzer/pipeline.py +++ b/ai-service/analyzer/pipeline.py @@ -3,6 +3,9 @@ import os import re import shutil +import signal +import sys +import tempfile import subprocess import threading import time @@ -21,13 +24,17 @@ project_synthesis_prompt, single_chunk_prompt, ) -from .scanner import scan_repository +from .scanner import scan_repository, select_analysis_files, read_repository_text class AnalysisError(RuntimeError): pass +class AnalysisBusy(AnalysisError): + pass + + STAGES = [ ("ollama", "Verificando Ollama"), ("clone", "Clonando repositório"), @@ -41,6 +48,14 @@ class AnalysisError(RuntimeError): STAGE_LABELS["error"] = "Falha na análise" +class AnalysisPaused(Exception): + pass + + +class AnalysisCancelled(Exception): + pass + + class Analyzer: def __init__(self, settings: AnalyzerSettings): self.settings = settings @@ -52,11 +67,29 @@ def __init__(self, settings: AnalyzerSettings): think=settings.ollama_think, max_retries=settings.ollama_max_retries, retry_backoff_seconds=settings.ollama_retry_backoff_seconds, + num_predict=settings.ollama_num_predict, + num_ctx=settings.ollama_num_ctx, ) self.runs: dict[str, RunState] = {} self.lock = threading.Lock() + self.checkpoint_lock = threading.Lock() + self.admission_lock = threading.RLock() + self.worker_slots = threading.BoundedSemaphore(settings.max_active_runs) + self._restore_runs() + + def start(self, url: str, profile: str = "quick", run_id: str | None = None) -> str: + with self.admission_lock: + return self._start(url, profile, run_id) + + def _check_capacity(self): + with self.lock: + active = sum(s.status in {"queued", "running", "pausing", "cancelling"} for s in self.runs.values()) + if active >= self.settings.max_active_runs + self.settings.max_queued_runs: + raise AnalysisBusy("Fila de análises cheia. Tente novamente mais tarde.") + if self._directory_size(self.settings.workspace_dir) >= self.settings.workspace_quota_mb * 1024**2: + raise AnalysisBusy("Cota de armazenamento das análises atingida.") - def start(self, url: str) -> str: + def _start(self, url: str, profile: str, run_id: str | None) -> str: normalized_url = self._normalize_github_url(url) if not normalized_url: raise AnalysisError( @@ -64,20 +97,183 @@ def start(self, url: str) -> str: "(ex.: https://github.com/usuario/repositorio)." ) - run_id = uuid.uuid4().hex[:12] - state = RunState(run_id=run_id, url=normalized_url, status="queued") + if profile not in {"quick", "balanced", "complete"}: + raise AnalysisError("Perfil inválido. Use quick, balanced ou complete.") + + run_id = run_id or uuid.uuid4().hex[:12] + if not re.fullmatch(r"[A-Za-z0-9-]{1,64}", run_id): + raise AnalysisError("Identificador de execução inválido.") + existing = self.runs.get(run_id) or self._load_persisted_run(run_id) + if existing: + if existing.url != normalized_url or existing.profile != profile: + raise AnalysisError("Identificador já usado para outra solicitação.") + return run_id + self._check_capacity() + state = RunState(run_id=run_id, url=normalized_url, status="queued", profile=profile) with self.lock: self.runs[run_id] = state + try: + self._persist_checkpoint(state) + except Exception: + with self.lock: + self.runs.pop(run_id, None) + raise + self._start_worker(state) + return run_id - thread = threading.Thread( - target=self._run, - args=(run_id,), - daemon=True, - ) + def _start_worker(self, state: RunState) -> None: + thread = threading.Thread(target=self._queued_run, args=(state.run_id,), daemon=True) + state.worker_thread = thread thread.start() - return run_id + + def _queued_run(self, run_id: str): + acquired = False + try: + while not acquired: + self._check_control(run_id) + acquired = self.worker_slots.acquire(timeout=0.1) + self._check_control(run_id) + self._run(run_id) + except AnalysisPaused: + self._push(run_id, status="paused", message="Análise pausada na fila.") + except AnalysisCancelled: + self._push(run_id, status="cancelled", message="Análise cancelada na fila.") + finally: + if acquired: + self.worker_slots.release() + + def pause(self, run_id: str) -> dict: + state = self._get_run(run_id) + with self.lock: + if state.status not in {"queued", "running"}: + raise AnalysisError("Só é possível pausar uma análise em andamento.") + state.status = "pausing" + state.message = "Aguardando concluir a etapa atual para salvar o progresso…" + state.pause_event.set() + state.stats = self._snapshot(state) + self._persist_checkpoint(state) + return self.status(run_id) + + def resume(self, run_id: str) -> dict: + with self.admission_lock: + return self._resume(run_id) + + def _resume(self, run_id: str) -> dict: + state = self._get_run(run_id) + if state.status in {"queued", "running", "completed"}: + return self.status(run_id) + has_progress = bool(state.completed_summaries or state.partial_chunk_summaries or state.project_summary) + if state.status != "paused" and not (state.status == "failed" and has_progress): + raise AnalysisError("A análise não está pausada ou não possui progresso recuperável.") + if state.worker_thread and state.worker_thread.is_alive(): + raise AnalysisError("Aguarde o encerramento da etapa atual antes de retomar.") + self._check_capacity() + state.pause_event.clear() + state.cancel_event.clear() + self._push(run_id, status="queued", error="", message="Retomando do último ponto salvo…") + self._start_worker(state) + return self.status(run_id) + + def cancel(self, run_id: str) -> dict: + state = self._get_run(run_id) + with self.admission_lock: + with self.lock: + if state.status not in {"cancelled", "cancelling", "completed", "failed"}: + was_paused = state.status == "paused" + state.pause_event.clear() + state.cancel_event.set() + state.status = "cancelled" if was_paused else "cancelling" + state.message = "Cancelamento solicitado." + state.stats = self._snapshot(state) + self._persist_checkpoint(state) + return self.status(run_id) + + def _get_run(self, run_id: str) -> RunState: + state = self.runs.get(run_id) or self._load_persisted_run(run_id) + if not state: + raise AnalysisError("Execução não encontrada.") + return state + + def _restore_runs(self) -> None: + runs_dir = self.settings.workspace_dir / "runs" + if not runs_dir.exists(): + return + for run_dir in runs_dir.iterdir(): + checkpoint_path = run_dir / "checkpoint.json" + if not checkpoint_path.exists(): + continue + try: + data = json.loads(checkpoint_path.read_text(encoding="utf-8")) + state = RunState( + run_id=data["run_id"], + url=data["url"], + profile=data.get("profile", "quick"), + status=data.get("status", "paused"), + stage=data.get("stage", ""), + message=data.get("message", ""), + error=data.get("error", ""), + report_path=data.get("report_path", ""), + started_at=data.get("started_at", 0), + files_total=data.get("files_total", 0), + files_processed=data.get("files_processed", 0), + files_ignored=data.get("files_ignored", 0), + files_candidates=data.get("files_candidates", 0), + files_skipped_by_scope=data.get("files_skipped_by_scope", 0), + language_counts=data.get("language_counts", {}), + selected_paths=data.get("selected_paths", []), + completed_summaries=data.get("completed_summaries", {}), + partial_chunk_summaries=data.get("partial_chunk_summaries", {}), + project_summary=data.get("project_summary", ""), + llm_calls_done=data.get("llm_calls_done", 0), + llm_calls_estimated=data.get("llm_calls_estimated", 0), + ) + if state.status == "cancelling": + state.status = "cancelled" + state.message = "Análise cancelada durante o encerramento do serviço." + elif state.status in {"queued", "running", "pausing"}: + state.status = "paused" + state.message = "Serviço reiniciado. O progresso foi salvo; retome para continuar." + state.stats = self._snapshot(state) + self.runs[state.run_id] = state + self._persist_checkpoint(state) + except (OSError, ValueError, KeyError, TypeError): + continue def _load_persisted_run(self, run_id: str) -> RunState | None: + if not re.fullmatch(r"[A-Za-z0-9-]{1,64}", run_id): + raise AnalysisError("Identificador de execução inválido.") + checkpoint_path = self.settings.workspace_dir / "runs" / run_id / "checkpoint.json" + if checkpoint_path.exists(): + try: + data = json.loads(checkpoint_path.read_text(encoding="utf-8")) + state = RunState( + run_id=data["run_id"], url=data["url"], profile=data.get("profile", "quick"), + status=data.get("status", "paused"), stage=data.get("stage", ""), + message=data.get("message", ""), error=data.get("error", ""), + report_path=data.get("report_path", ""), started_at=data.get("started_at", 0), + files_total=data.get("files_total", 0), files_processed=data.get("files_processed", 0), + files_ignored=data.get("files_ignored", 0), files_candidates=data.get("files_candidates", 0), + files_skipped_by_scope=data.get("files_skipped_by_scope", 0), + language_counts=data.get("language_counts", {}), selected_paths=data.get("selected_paths", []), + completed_summaries=data.get("completed_summaries", {}), + partial_chunk_summaries=data.get("partial_chunk_summaries", {}), + project_summary=data.get("project_summary", ""), + llm_calls_done=data.get("llm_calls_done", 0), llm_calls_estimated=data.get("llm_calls_estimated", 0), + ) + if state.status == "cancelling": + state.status = "cancelled" + state.message = "Análise cancelada durante o encerramento do serviço." + elif state.status in {"queued", "running", "pausing"}: + state.status = "paused" + state.message = "Serviço reiniciado. O progresso foi salvo; retome para continuar." + state.stats = self._snapshot(state) + with self.lock: + self.runs[run_id] = state + self._persist_checkpoint(state) + return state + except (OSError, ValueError, KeyError, TypeError): + pass + report_dir = self.settings.workspace_dir / "reports" / run_id report_path = report_dir / "report.md" if not report_path.exists(): @@ -143,6 +339,11 @@ def status(self, run_id: str) -> dict: "message": state.message, "error": state.error, "report_path": state.report_path, + "profile": state.profile, + "can_resume": state.status == "paused" or ( + state.status == "failed" and bool(state.completed_summaries or state.partial_chunk_summaries or state.project_summary) + ), + "can_cancel": state.status in {"queued", "running", "pausing", "paused"}, "stats": state.stats, } @@ -209,6 +410,13 @@ def _snapshot(self, state: RunState) -> dict: "files_total": state.files_total, "files_processed": state.files_processed, "files_ignored": state.files_ignored, + "files_candidates": state.files_candidates, + "files_selected": state.files_total, + "files_skipped_by_scope": state.files_skipped_by_scope, + "profile": state.profile, + "can_resume": state.status == "paused" or ( + state.status == "failed" and bool(state.completed_summaries or state.partial_chunk_summaries or state.project_summary) + ), "files_progress_percent": files_progress_percent, "language_counts": state.language_counts, "current_files": list(state.current_files), @@ -226,6 +434,30 @@ def _push(self, run_id: str, **field_updates): for key, value in field_updates.items(): setattr(state, key, value) state.stats = self._snapshot(state) + self._persist_checkpoint(state) + + def _persist_checkpoint(self, state: RunState) -> None: + run_dir = self.settings.workspace_dir / "runs" / state.run_id + run_dir.mkdir(parents=True, exist_ok=True) + checkpoint = run_dir / "checkpoint.json" + temporary = checkpoint.with_suffix(".json.tmp") + with self.checkpoint_lock: + with self.lock: + payload = { + "run_id": state.run_id, "url": state.url, "profile": state.profile, + "status": state.status, "stage": state.stage, "message": state.message, + "error": state.error, "report_path": state.report_path, "started_at": state.started_at, + "files_total": state.files_total, "files_processed": state.files_processed, + "files_ignored": state.files_ignored, "files_candidates": state.files_candidates, + "files_skipped_by_scope": state.files_skipped_by_scope, + "language_counts": dict(state.language_counts), "selected_paths": list(state.selected_paths), + "completed_summaries": dict(state.completed_summaries), + "partial_chunk_summaries": {path: list(summaries) for path, summaries in state.partial_chunk_summaries.items()}, + "project_summary": state.project_summary, + "llm_calls_done": state.llm_calls_done, "llm_calls_estimated": state.llm_calls_estimated, + } + temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + temporary.replace(checkpoint) def _note_llm_call(self, run_id: str): with self.lock: @@ -241,13 +473,15 @@ def _file_started(self, run_id: str, path: str): state.current_files.append(path) state.stats = self._snapshot(state) - def _file_finished(self, run_id: str, path: str): + def _file_finished(self, run_id: str, path: str, succeeded: bool): with self.lock: state = self.runs[run_id] if path in state.current_files: state.current_files.remove(path) - state.files_processed += 1 + if succeeded: + state.files_processed += 1 state.stats = self._snapshot(state) + self._persist_checkpoint(state) def _estimate_llm_calls(self, files) -> int: max_chunk_chars = max(1, self.settings.max_chunk_chars) @@ -257,6 +491,13 @@ def _estimate_llm_calls(self, files) -> int: total_calls += 1 if chunks_est <= 1 else chunks_est + 1 return total_calls + def _check_control(self, run_id: str) -> None: + state = self.runs[run_id] + if state.cancel_event.is_set(): + raise AnalysisCancelled() + if state.pause_event.is_set(): + raise AnalysisPaused() + def _run(self, run_id: str): state = self.runs[run_id] run_dir = self.settings.workspace_dir / "runs" / run_id @@ -265,19 +506,23 @@ def _run(self, run_id: str): report_dir.mkdir(parents=True, exist_ok=True) try: - self._push( - run_id, - status="running", - stage="ollama", - message="Verificando se o Ollama está no ar e o modelo instalado...", - started_at=time.time(), - ) + self._push(run_id, status="running", stage=state.stage or "ollama", + message="Verificando se o Ollama está no ar e o modelo instalado...", + started_at=state.started_at or time.time()) + self._check_control(run_id) self.client.check() run_dir.mkdir(parents=True, exist_ok=True) - self._push(run_id, stage="clone", message="Clonando repositório (raso, 1 commit)...") - self._clone(state.url, repo_dir) + self._check_control(run_id) + clone_marker = run_dir / "clone.complete" + if not repo_dir.exists() or not clone_marker.exists(): + state.completed_summaries.clear() + state.partial_chunk_summaries.clear() + state.project_summary = "" + self._push(run_id, stage="clone", message="Clonando repositório (raso, 1 commit)...") + self._clone(state.url, repo_dir) + clone_marker.write_text("complete", encoding="utf-8") size_mb = self._directory_size(repo_dir) / (1024 * 1024) if size_mb > self.settings.max_repo_size_mb: @@ -285,6 +530,7 @@ def _run(self, run_id: str): f"Repositório excede o limite de {self.settings.max_repo_size_mb} MB." ) + self._check_control(run_id) self._push(run_id, stage="scan", message="Inventariando arquivos do repositório...") inventory = scan_repository( repo_dir, @@ -292,29 +538,46 @@ def _run(self, run_id: str): self.settings.max_files, ) - files = inventory["files"] + candidates = inventory["files"] + files = select_analysis_files( + candidates, + state.profile, + self.settings.quick_profile_files, + self.settings.balanced_profile_files, + ) + state.selected_paths = [item.path for item in files] total = len(files) - llm_calls_estimated = self._estimate_llm_calls(files) + state.files_candidates = len(candidates) + state.files_skipped_by_scope = max(0, len(candidates) - total) + state.files_ignored = inventory["ignored_files"] + state.language_counts = inventory["language_counts"] + state.files_total = total + state.files_processed = sum(1 for item in files if item.path in state.completed_summaries) + llm_calls_estimated = state.llm_calls_done + self._estimate_llm_calls( + [item for item in files if item.path not in state.completed_summaries] + ) self._push( run_id, stage="files", message=( - f"Analisando {total} arquivo(s) com o modelo " + f"Analisando {total} de {len(candidates)} arquivo(s) elegíveis " + f"(perfil {state.profile}) com o modelo " f"{self.settings.ollama_model}..." ), files_total=total, - files_processed=0, + files_candidates=len(candidates), + files_skipped_by_scope=max(0, len(candidates) - total), + files_processed=state.files_processed, files_ignored=inventory["ignored_files"], language_counts=inventory["language_counts"], llm_calls_estimated=llm_calls_estimated, - llm_calls_done=0, - llm_start_time=0.0, current_files=[], ) file_summaries = self._analyze_files(run_id, repo_dir, files) + self._check_control(run_id) self._push( run_id, stage="synthesis", @@ -328,18 +591,33 @@ def _run(self, run_id: str): "ignored_files": inventory["ignored_files"], "language_counts": inventory["language_counts"], "ignored_examples": inventory["ignored"][:100], - "tree": [item.path for item in files], + "selected_files": [item.path for item in files], + "profile": state.profile, }, ensure_ascii=False, indent=2) synthesis_input, omitted_count = self._budget_summaries( file_summaries, self.settings.max_synthesis_chars ) - final = self.client.chat( - SYSTEM, - project_synthesis_prompt(inventory_text, synthesis_input), - ) - self._note_llm_call(run_id) + final = state.project_summary + if not final: + self._check_control(run_id) + final = self.client.chat( + SYSTEM, + project_synthesis_prompt( + inventory_text, + synthesis_input, + compact=state.profile == "quick", + ), + should_cancel=lambda: self._check_control(run_id), + num_predict=self.settings.ollama_synthesis_num_predict, + accept_truncated=state.profile == "quick", + ) + self._note_llm_call(run_id) + with self.lock: + state.project_summary = final + self._persist_checkpoint(state) + self._check_control(run_id) if omitted_count: final += ( @@ -349,10 +627,7 @@ def _run(self, run_id: str): "detalhados individualmente na seção 'Arquivos analisados')." ) - coverage = ( - (inventory["analyzed_candidates"] / inventory["total_files"] * 100) - if inventory["total_files"] else 100 - ) + coverage = (total / len(candidates) * 100) if candidates else 100 report = self._build_report( state.url, @@ -360,6 +635,9 @@ def _run(self, run_id: str): final, file_summaries, coverage, + state.profile, + total, + state.files_skipped_by_scope, ) report_path = report_dir / "report.md" @@ -370,6 +648,9 @@ def _run(self, run_id: str): "url": state.url, "coverage_percent": coverage, "total_files": inventory["total_files"], + "selected_files": total, + "skipped_by_scope": state.files_skipped_by_scope, + "profile": state.profile, "ignored_files": inventory["ignored"], "language_counts": inventory["language_counts"], }, ensure_ascii=False, indent=2), @@ -385,6 +666,11 @@ def _run(self, run_id: str): files_processed=total, ) + except AnalysisPaused: + self._push(run_id, status="paused", message="Progresso salvo. Retome a análise quando quiser.", current_files=[]) + except AnalysisCancelled: + self._push(run_id, status="cancelled", message="Análise cancelada pelo usuário.", current_files=[]) + except Exception as exc: self._push( run_id, @@ -399,10 +685,15 @@ def _analyze_files(self, run_id: str, repo_dir: Path, files) -> list[str]: max_workers = max(1, self.settings.ollama_concurrency) file_summaries: list[str | None] = [None] * len(files) + for idx, info in enumerate(files): + if info.path in self.runs[run_id].completed_summaries: + file_summaries[idx] = self.runs[run_id].completed_summaries[info.path] + with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = { executor.submit(self._process_file, run_id, repo_dir, info): idx for idx, info in enumerate(files) + if file_summaries[idx] is None } try: for future in as_completed(futures): @@ -412,25 +703,38 @@ def _analyze_files(self, run_id: str, repo_dir: Path, files) -> list[str]: executor.shutdown(wait=False, cancel_futures=True) raise - return file_summaries + return [summary or "" for summary in file_summaries] def _process_file(self, run_id: str, repo_dir: Path, info) -> str: + self._check_control(run_id) self._file_started(run_id, info.path) + succeeded = False try: path = repo_dir / info.path - text = path.read_text(encoding="utf-8", errors="replace") + text = read_repository_text(repo_dir.resolve(), path.absolute(), self.settings.max_file_size_kb * 1024) chunks = self._chunks(text, self.settings.max_chunk_chars) symbols_json = json.dumps(info.symbols, ensure_ascii=False) + state = self.runs[run_id] if len(chunks) == 1: synthesis = self.client.chat( SYSTEM, - single_chunk_prompt(info.path, info.language, symbols_json, chunks[0]), + single_chunk_prompt( + info.path, + info.language, + symbols_json, + chunks[0], + compact=state.profile == "quick", + ), + should_cancel=lambda: self._check_control(run_id), + num_predict=self.settings.ollama_file_num_predict, + accept_truncated=state.profile == "quick", ) self._note_llm_call(run_id) else: - chunk_summaries = [] - for chunk_index, chunk in enumerate(chunks, start=1): + chunk_summaries = list(state.partial_chunk_summaries.get(info.path, [])) + for chunk_index, chunk in enumerate(chunks[len(chunk_summaries):], start=len(chunk_summaries) + 1): + self._check_control(run_id) prompt = chunk_prompt( info.path, info.language, @@ -438,10 +742,21 @@ def _process_file(self, run_id: str, repo_dir: Path, info) -> str: len(chunks), chunk, symbols_json, + compact=state.profile == "quick", ) - chunk_summaries.append(self.client.chat(SYSTEM, prompt)) + chunk_summaries.append(self.client.chat( + SYSTEM, + prompt, + should_cancel=lambda: self._check_control(run_id), + num_predict=self.settings.ollama_file_num_predict, + accept_truncated=state.profile == "quick", + )) self._note_llm_call(run_id) + with self.lock: + state.partial_chunk_summaries[info.path] = list(chunk_summaries) + self._persist_checkpoint(state) + self._check_control(run_id) budgeted_chunk_summaries, omitted_chunks = self._budget_summaries( chunk_summaries, self.settings.max_synthesis_chars ) @@ -453,7 +768,11 @@ def _process_file(self, run_id: str, repo_dir: Path, info) -> str: info.language, symbols_json, budgeted_chunk_summaries, + compact=state.profile == "quick", ), + should_cancel=lambda: self._check_control(run_id), + num_predict=self.settings.ollama_file_num_predict, + accept_truncated=state.profile == "quick", ) self._note_llm_call(run_id) @@ -466,15 +785,20 @@ def _process_file(self, run_id: str, repo_dir: Path, info) -> str: info.analyzed = True info.chunks = len(chunks) info.summary = synthesis - - return ( + summary = ( f"ARQUIVO: {info.path}\n" f"LINGUAGEM: {info.language}\n" f"SÍMBOLOS: {symbols_json}\n" f"ANÁLISE:\n{synthesis}" ) + state = self.runs[run_id] + with self.lock: + state.completed_summaries[info.path] = summary + state.partial_chunk_summaries.pop(info.path, None) + succeeded = True + return summary finally: - self._file_finished(run_id, info.path) + self._file_finished(run_id, info.path, succeeded) @staticmethod def _normalize_github_url(url: str) -> str | None: @@ -509,32 +833,36 @@ def _clone(self, url: str, destination: Path): destination.parent.mkdir(parents=True, exist_ok=True) - try: - result = subprocess.run( - [ - "git", "clone", - "--depth", "1", - "--single-branch", - "--no-tags", - url, - str(destination), - ], - capture_output=True, - text=True, - timeout=300, - ) - except FileNotFoundError as exc: - raise AnalysisError( - "Git não foi encontrado no PATH. Instale o Git e tente novamente." - ) from exc - except subprocess.TimeoutExpired as exc: - raise AnalysisError("O clone excedeu o tempo limite.") from exc - - if result.returncode != 0: - raise AnalysisError( - "Falha ao clonar o repositório:\n" + - (result.stderr.strip() or result.stdout.strip()) - ) + run_id = destination.parent.name + command = [sys.executable, "-m", "analyzer.limited_git", str(self.settings.max_repo_size_mb), + str(self.settings.clone_memory_mb), str(self.settings.clone_timeout_seconds), + "clone", "--depth", "1", "--single-branch", "--no-tags", url, str(destination)] + started = time.monotonic() + with tempfile.TemporaryFile() as output: + process = subprocess.Popen(command, stdout=output, stderr=output, start_new_session=True, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_LFS_SKIP_SMUDGE": "1"}) + try: + while process.poll() is None: + if run_id in self.runs: + self._check_control(run_id) + if time.monotonic() - started > self.settings.clone_timeout_seconds: + raise AnalysisError("O clone excedeu o tempo limite.") + if self._directory_size(destination) > self.settings.max_repo_size_mb * 1024**2: + raise AnalysisError("O clone excedeu a cota de disco por repositório.") + if self._directory_size(self.settings.workspace_dir) > self.settings.workspace_quota_mb * 1024**2: + raise AnalysisError("O clone excedeu a cota global de disco.") + time.sleep(0.1) + if process.returncode != 0: + raise AnalysisError("Falha ao clonar o repositório ou limite de recursos atingido.") + if self._directory_size(destination) > self.settings.max_repo_size_mb * 1024**2: + raise AnalysisError("O clone excedeu a cota de disco por repositório.") + except BaseException: + if process.poll() is None: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + if destination.exists(): + shutil.rmtree(destination) + raise @staticmethod def _directory_size(path: Path) -> int: @@ -589,7 +917,9 @@ def _chunks(text: str, max_chars: int) -> list[str]: return chunks @staticmethod - def _build_report(url, inventory, final, file_summaries, coverage): + def _build_report(url, inventory, final, file_summaries, coverage, profile="complete", + selected_count=None, skipped_by_scope=0): + selected_count = len(file_summaries) if selected_count is None else selected_count lines = [ "# Repository Intelligence Report", "", @@ -598,7 +928,10 @@ def _build_report(url, inventory, final, file_summaries, coverage): "## Cobertura da análise", "", f"- Arquivos de texto elegíveis: **{inventory['total_files']}**", - f"- Arquivos analisados: **{inventory['analyzed_candidates']}**", + f"- Arquivos elegíveis encontrados: **{inventory['analyzed_candidates']}**", + f"- Arquivos selecionados pelo perfil `{profile}`: **{selected_count}**", + f"- Arquivos fora do escopo selecionado: **{skipped_by_scope}**", + f"- Arquivos analisados: **{len(file_summaries)}**", f"- Arquivos ignorados: **{inventory['ignored_files']}**", f"- Cobertura de arquivos elegíveis: **{coverage:.2f}%**", "", diff --git a/ai-service/analyzer/prompts.py b/ai-service/analyzer/prompts.py index d906f5a..fd67edf 100644 --- a/ai-service/analyzer/prompts.py +++ b/ai-service/analyzer/prompts.py @@ -16,7 +16,18 @@ """ -def chunk_prompt(path, language, chunk_index, total_chunks, content, symbols): +def chunk_prompt(path, language, chunk_index, total_chunks, content, symbols, compact=False): + if compact: + return f"""Analise o bloco {chunk_index}/{total_chunks} do arquivo `{path}` ({language}). + +Símbolos conhecidos: {symbols} +Conteúdo: +```text +{content} +``` + +Em português, resuma propósito e fatos principais em até 180 palavras. Liste só funções/dependências relevantes e um possível problema se houver evidência. Não repita o conteúdo nem invente fatos.""" + return f"""Analise o bloco {chunk_index}/{total_chunks} do arquivo `{path}`. Linguagem: {language} @@ -41,11 +52,24 @@ def chunk_prompt(path, language, chunk_index, total_chunks, content, symbols): Não invente informações ausentes.""" -def single_chunk_prompt(path, language, symbols, content): +def single_chunk_prompt(path, language, symbols, content, compact=False): """Usado quando o arquivo inteiro cabe em um único bloco: produz diretamente a análise consolidada do arquivo em UMA chamada ao modelo, em vez de uma chamada por bloco seguida de uma chamada de consolidação. """ + if compact: + return f"""Analise o arquivo `{path}` ({language}) por completo. + +Símbolos conhecidos: +{symbols} + +Conteúdo: +```text +{content} +``` + +Em português, descreva finalidade, funcionamento e evidências principais em até 250 palavras. Cite funções/imports relevantes e possíveis problemas apenas quando houver evidência. Não invente fatos.""" + return f"""Analise o arquivo `{path}` ({language}) por completo - ele cabe integralmente em um único bloco, então esta é a análise final e consolidada do arquivo (não apenas de um trecho). @@ -71,8 +95,17 @@ def single_chunk_prompt(path, language, symbols, content): Não invente informações ausentes.""" -def file_synthesis_prompt(path, language, symbols, chunk_summaries): +def file_synthesis_prompt(path, language, symbols, chunk_summaries, compact=False): joined = "\n\n--- BLOCO ---\n\n".join(chunk_summaries) + if compact: + return f"""Consolide em até 300 palavras a análise de `{path}` ({language}). + +Estrutura estática: {symbols} +Resumo dos blocos: +{joined} + +Use seções curtas para finalidade, responsabilidades, funcionamento e evidências. Identifique possíveis problemas só se houver evidência; declare quando algo não puder ser determinado.""" + return f"""Consolide a análise do arquivo `{path}` ({language}). Estrutura estática: @@ -94,9 +127,29 @@ def file_synthesis_prompt(path, language, symbols, chunk_summaries): Não introduza fatos que não apareçam nas evidências.""" -def project_synthesis_prompt(inventory, file_summaries): +def project_synthesis_prompt(inventory, file_summaries, compact=False): joined = "\n\n===== ARQUIVO =====\n\n".join(file_summaries) + if compact: + return f"""Você é o analista principal do repositório. + +INVENTÁRIO: +{inventory} + +ANÁLISES DOS ARQUIVOS: +{joined} + +Escreva uma síntese concisa em Markdown, com estas seções e nesta ordem: +# Visão Geral +# Objetivo Inferido + # Stack Tecnológica +# Arquitetura e Estrutura +# Funcionalidades Observadas +# Qualidade e Possíveis Problemas +# Limitações da Análise + +Use até 2 frases por seção e até 350 palavras no total. Para cada tecnologia, cite o arquivo que comprova seu uso ou declaração; imports padrão da linguagem/framework não são dependências separadas. Liste dependências externas somente quando aparecerem no inventário/manifests fornecidos. Para cada possível problema, cite arquivo e trecho/função que o demonstra e descreva a condição de falha; se não houver evidência suficiente, não inclua o item. Não transforme hipóteses em funcionalidades, não repita itens e não invente bibliotecas. Separe fatos observados de inferências e recomendações. Quando faltar evidência, escreva “não determinado pelas evidências disponíveis”.""" + return f"""Você é o analista principal do repositório. INVENTÁRIO: @@ -127,4 +180,6 @@ def project_synthesis_prompt(inventory, file_summaries): # Evidências Concretas Se alguma seção não puder ser determinada, declare explicitamente isso. -Não invente tecnologias, funcionalidades ou arquitetura.""" +Não invente tecnologias, funcionalidades ou arquitetura. +Mantenha cada seção objetiva e evite repetir a lista de arquivos do inventário. +Use apenas evidências que aparecem nas análises fornecidas.""" diff --git a/ai-service/analyzer/scanner.py b/ai-service/analyzer/scanner.py index 317e84d..b8ca7b7 100644 --- a/ai-service/analyzer/scanner.py +++ b/ai-service/analyzer/scanner.py @@ -1,5 +1,7 @@ import ast import os +import stat +from contextlib import contextmanager from pathlib import Path from collections import Counter @@ -65,6 +67,38 @@ ".env.example", ".gitignore" } +def select_analysis_files(files: list[FileInfo], profile: str, quick_limit: int = 8, + balanced_limit: int = 80) -> list[FileInfo]: + """Pick the most useful files first while keeping selection deterministic.""" + limits = {"quick": max(1, quick_limit), "balanced": max(1, balanced_limit), "complete": None} + if profile not in limits: + raise ValueError("Perfil de análise inválido. Use quick, balanced ou complete.") + + def priority(info: FileInfo) -> tuple[int, str]: + name = Path(info.path).name + lower = info.path.lower() + score = 0 + if name.upper().startswith("README"): + score += 100 + if name in {"package.json", "pyproject.toml", "requirements.txt", "Cargo.toml", "go.mod", + "pom.xml", "build.gradle", "Dockerfile", "docker-compose.yml", "docker-compose.yaml"}: + score += 80 + if info.language not in {"Unknown", "Markdown", "Text", "JSON", "YAML", "TOML", "XML"}: + score += 50 + if any(part in lower for part in ("/test/", "/tests/", "_test.", ".test.", ".spec.")): + score -= 15 + if info.kind == "documentation": + score -= 20 + if name.lower() in {"package-lock.json", "yarn.lock", "pnpm-lock.yaml"}: + score -= 40 + if lower.startswith(("examples/", "fixtures/", "samples/")): + score -= 20 + return score, info.path + + ranked = sorted(files, key=lambda info: (-priority(info)[0], priority(info)[1])) + limit = limits[profile] + return ranked if limit is None else ranked[:limit] + def detect_language(path: Path) -> str: if path.name == "Dockerfile": @@ -72,14 +106,54 @@ def detect_language(path: Path) -> str: return LANGUAGES.get(path.suffix.lower(), "Unknown") -def is_probably_binary(path: Path) -> bool: +@contextmanager +def open_repository_file(root: Path, path: Path): + """Walk with no-follow descriptors: reject symlinks, special files and escapes.""" + root = root.resolve(strict=True) + relative = path.absolute().relative_to(root) + if not relative.parts or any(part in {"..", "."} for part in relative.parts): + raise ValueError("Invalid repository path") + # Analyzer runs on Linux; descriptor-relative opens also prevent replacement races. + descriptors = [] + try: + current = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + descriptors.append(current) + for part in relative.parts[:-1]: + current = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=current) + descriptors.append(current) + fd = os.open(relative.parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=current) + descriptors.append(fd) + if not stat.S_ISREG(os.fstat(fd).st_mode): + raise ValueError("Not a regular repository file") + with os.fdopen(os.dup(fd), "rb") as handle: + yield handle + finally: + for fd in reversed(descriptors): + os.close(fd) + + +def read_repository_text(root: Path, path: Path, max_bytes: int) -> str: + with open_repository_file(root, path) as handle: + data = handle.read(max_bytes + 1) + if len(data) > max_bytes: + raise ValueError("Repository file exceeds size limit") + if data.startswith((b"\x00\x00\xfe\xff", b"\xff\xfe\x00\x00")): + return data.decode("utf-32", errors="replace") + if data.startswith((b"\xff\xfe", b"\xfe\xff")): + return data.decode("utf-16", errors="replace") + return data.decode("utf-8-sig", errors="replace") + + +def is_probably_binary(path: Path, root: Path | None = None) -> bool: if path.suffix.lower() in BINARY_EXTENSIONS: return True try: - with open(path, "rb") as handle: + with open_repository_file(root or path.absolute().parent, path) as handle: data = handle.read(4096) + if data.startswith((b"\xff\xfe", b"\xfe\xff", b"\x00\x00\xfe\xff", b"\xff\xfe\x00\x00")): + return False return b"\x00" in data - except OSError: + except (OSError, ValueError): return True @@ -115,23 +189,28 @@ def python_symbols(text: str) -> dict: def scan_repository(repo_dir: Path, max_file_size: int, max_files: int): + repo_dir = repo_dir.resolve(strict=True) files: list[FileInfo] = [] ignored: list[dict] = [] for current, dirs, filenames in os.walk(repo_dir): - dirs[:] = [d for d in dirs if d not in IGNORED_DIRS] + dirs[:] = [d for d in dirs if d not in IGNORED_DIRS and not (Path(current) / d).is_symlink()] for filename in filenames: path = Path(current) / filename relative = path.relative_to(repo_dir).as_posix() try: - size = path.stat().st_size - except OSError: + metadata = path.lstat() + if not stat.S_ISREG(metadata.st_mode) or not path.resolve(strict=True).is_relative_to(repo_dir): + ignored.append({"path": relative, "reason": "unsafe_file"}) + continue + size = metadata.st_size + except (OSError, ValueError): ignored.append({"path": relative, "reason": "stat_error"}) continue - if is_probably_binary(path): + if is_probably_binary(path, repo_dir): ignored.append({"path": relative, "reason": "binary"}) continue @@ -158,9 +237,9 @@ def scan_repository(repo_dir: Path, max_file_size: int, max_files: int): if language == "Python": try: - text = path.read_text(encoding="utf-8", errors="replace") + text = read_repository_text(repo_dir, path, max_file_size) info.symbols = python_symbols(text) - except OSError: + except (OSError, ValueError): pass files.append(info) diff --git a/ai-service/main.py b/ai-service/main.py index 50bec75..82c716a 100644 --- a/ai-service/main.py +++ b/ai-service/main.py @@ -4,7 +4,7 @@ from fastapi.responses import JSONResponse from document_text import extract_document_text from contextlib import asynccontextmanager -from typing import Any +from typing import Any, Literal from fastapi import FastAPI, HTTPException, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import PlainTextResponse @@ -14,6 +14,7 @@ from services.ollama_client import ollama_client from services.chunker import chunk_document_text, create_structured_chunk from analyzer import Analyzer, AnalysisError, AnalyzerSettings +from analyzer.pipeline import AnalysisBusy analyzer = Analyzer(AnalyzerSettings()) @@ -215,20 +216,52 @@ async def query_rag(req: RagQueryRequest): class AnalyzeRequest(BaseModel): url: str = Field(..., description="URL pública do repositório GitHub") + run_id: str | None = Field(None, pattern=r"^[A-Za-z0-9-]{1,64}$") + profile: Literal["quick", "balanced", "complete"] = Field( + "quick", description="Quantidade e prioridade dos arquivos enviados ao modelo local" + ) @app.post("/api/analyze", status_code=status.HTTP_200_OK) def analyze_repository(req: AnalyzeRequest): """Inicia a análise assíncrona de um repositório GitHub.""" try: - run_id = analyzer.start(str(req.url)) + run_id = analyzer.start(str(req.url), req.profile, req.run_id) return {"run_id": run_id, "status": "started"} + except AnalysisBusy as exc: + raise HTTPException(status_code=429, detail=str(exc)) except AnalysisError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) except Exception as exc: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) +@app.post("/api/runs/{run_id}/pause") +def pause_analysis(run_id: str): + try: + return analyzer.pause(run_id) + except AnalysisError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) + + +@app.post("/api/runs/{run_id}/resume") +def resume_analysis(run_id: str): + try: + return analyzer.resume(run_id) + except AnalysisBusy as exc: + raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(exc)) + except AnalysisError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) + + +@app.post("/api/runs/{run_id}/cancel") +def cancel_analysis(run_id: str): + try: + return analyzer.cancel(run_id) + except AnalysisError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) + + @app.get("/api/runs") def list_analysis_runs(): """Lista as execuções de análise ativas e persistidas.""" diff --git a/ai-service/services/chunker.py b/ai-service/services/chunker.py index 2b4432b..b6e5903 100644 --- a/ai-service/services/chunker.py +++ b/ai-service/services/chunker.py @@ -8,45 +8,30 @@ def chunk_document_text(text: str, chunk_size: int = 1000, overlap: int = 150) - with an overlap of ~150 chars, breaking primarily on paragraph or sentence boundaries. Complies with Sinapse PRD Section 10.3. """ + if chunk_size <= 0 or not 0 <= overlap < chunk_size: + raise ValueError("Require chunk_size > 0 and 0 <= overlap < chunk_size") if not text or not text.strip(): return [] # Normalize newlines text = text.replace("\r\n", "\n") - paragraphs = re.split(r"\n\s*\n", text) - + text = text.strip() chunks: list[str] = [] - current_chunk: list[str] = [] - current_len = 0 - - for para in paragraphs: - para = para.strip() - if not para: - continue - - para_len = len(para) - - if current_len + para_len <= chunk_size: - current_chunk.append(para) - current_len += para_len + 1 - else: - if current_chunk: - chunk_str = "\n\n".join(current_chunk) - chunks.append(chunk_str) - # Keep overlap if possible - overlap_text = chunk_str[-overlap:] if len(chunk_str) > overlap else "" - current_chunk = [overlap_text, para] if overlap_text else [para] - current_len = sum(len(p) for p in current_chunk) + len(current_chunk) - 1 - else: - # Single paragraph exceeds chunk_size, break by sentence or length - chunks.append(para[:chunk_size]) - current_chunk = [para[chunk_size - overlap:]] - current_len = len(current_chunk[0]) - - if current_chunk: - chunks.append("\n\n".join(current_chunk)) - - return [c.strip() for c in chunks if c.strip()] + start = 0 + while start < len(text): + end = min(start + chunk_size, len(text)) + if end < len(text): + # Prefer a paragraph boundary only when it leaves meaningful progress. + boundary = text.rfind("\n\n", start + max(overlap + 1, chunk_size // 2), end) + if boundary >= 0: + end = boundary + 2 + chunk = text[start:end] + if chunk.strip(): + chunks.append(chunk) + if end == len(text): + break + start = end - overlap + return chunks def create_structured_chunk(entity_type: str, data: dict[str, Any]) -> dict[str, Any]: diff --git a/ai-service/tests/test_analyzer.py b/ai-service/tests/test_analyzer.py index 752ddb8..e0cd4a8 100644 --- a/ai-service/tests/test_analyzer.py +++ b/ai-service/tests/test_analyzer.py @@ -1,15 +1,21 @@ import unittest +from unittest.mock import patch from pathlib import Path +from tempfile import TemporaryDirectory from analyzer.pipeline import Analyzer, STAGE_KEYS, STAGE_LABELS from analyzer.config import AnalyzerSettings -from analyzer.scanner import detect_language, python_symbols, is_probably_binary -from analyzer.models import RunState +from analyzer.scanner import detect_language, python_symbols, is_probably_binary, select_analysis_files +from analyzer.models import FileInfo, RunState +from analyzer.prompts import project_synthesis_prompt class TestAnalyzer(unittest.TestCase): def setUp(self): - self.settings = AnalyzerSettings(workspace_dir=Path("test_workspace")) + workspace = TemporaryDirectory() + self.addCleanup(workspace.cleanup) + self.settings = AnalyzerSettings(workspace_dir=Path(workspace.name)) self.analyzer = Analyzer(self.settings) + self.addCleanup(self.analyzer.client.close) def test_normalize_github_url(self): # Valid URLs @@ -42,6 +48,12 @@ def test_detect_language(self): self.assertEqual(detect_language(Path("Dockerfile")), "Dockerfile") self.assertEqual(detect_language(Path("unknown.xyz")), "Unknown") + def test_utf16_text_manifest_is_not_misclassified_as_binary(self): + with TemporaryDirectory() as directory: + manifest = Path(directory) / "requirements.txt" + manifest.write_text("Flask==3.1.0\n", encoding="utf-16") + self.assertFalse(is_probably_binary(manifest)) + def test_python_symbols_extraction(self): code = """ import os @@ -85,6 +97,154 @@ def test_stages_consistency(self): for key in STAGE_KEYS: self.assertIn(key, STAGE_LABELS) + def test_quick_project_synthesis_requires_evidence_for_stack_and_findings(self): + prompt = project_synthesis_prompt("manifest: requirements.txt", "src/app.py: imports flask", compact=True) + self.assertIn("até 350 palavras", prompt) + self.assertIn("cite o arquivo", prompt) + self.assertIn("não são dependências separadas", prompt) + self.assertIn("não invente bibliotecas", prompt) + + def test_analysis_profiles_prioritize_core_files_and_bound_llm_scope(self): + files = [ + FileInfo("src/service.py", 100, ".py", "Python", "source/config"), + FileInfo("README.md", 100, ".md", "Markdown", "documentation"), + FileInfo("tests/test_service.py", 100, ".py", "Python", "source/config"), + FileInfo("package-lock.json", 100, ".json", "JSON", "source/config"), + ] + quick = select_analysis_files(files, "quick", quick_limit=2) + self.assertEqual([item.path for item in quick], ["README.md", "src/service.py"]) + self.assertEqual(len(select_analysis_files(files, "balanced", balanced_limit=3)), 3) + self.assertEqual(len(select_analysis_files(files, "complete")), len(files)) + with self.assertRaises(ValueError): + select_analysis_files(files, "unknown") + + def test_quick_profile_defaults_to_eight_priority_files(self): + files = [ + FileInfo(f"src/module_{index}.py", 100, ".py", "Python", "source/config") + for index in range(12) + ] + self.assertEqual(len(select_analysis_files(files, "quick")), 8) + + def test_failed_analysis_with_saved_summaries_can_resume_without_repeating_them(self): + state = RunState( + run_id="failedresume1", url="https://github.com/acme/api", status="failed", + profile="quick", stage="files", files_total=2, files_processed=1, + selected_paths=["README.md", "src/app.py"], + completed_summaries={"README.md": "resumo salvo"}, + ) + self.analyzer.runs[state.run_id] = state + state.stats = self.analyzer._snapshot(state) + self.assertTrue(self.analyzer.status(state.run_id)["can_resume"]) + self.assertTrue(self.analyzer.status(state.run_id)["stats"]["can_resume"]) + + with patch.object(self.analyzer, "_start_worker") as start_worker: + result = self.analyzer.resume(state.run_id) + + self.assertEqual(result["status"], "queued") + self.assertEqual(self.analyzer.runs[state.run_id].error, "") + start_worker.assert_called_once_with(state) + + def test_checkpoint_restores_running_analysis_as_resumable_and_keeps_finished_summaries(self): + with TemporaryDirectory() as workspace: + settings = AnalyzerSettings(workspace_dir=Path(workspace)) + original = Analyzer(settings) + state = RunState( + run_id="checkpoint1", url="https://github.com/acme/api", status="running", + stage="files", profile="quick", files_total=2, files_processed=1, + selected_paths=["README.md", "src/app.py"], + completed_summaries={"README.md": "resumo salvo"}, + partial_chunk_summaries={"src/app.py": ["primeiro bloco salvo"]}, + ) + original.runs[state.run_id] = state + original._persist_checkpoint(state) + + restored = Analyzer(settings) + saved = restored.status(state.run_id) + self.assertEqual(saved["status"], "paused") + self.assertTrue(saved["can_resume"]) + self.assertEqual(saved["stats"]["files_processed"], 1) + self.assertEqual(restored.runs[state.run_id].completed_summaries, {"README.md": "resumo salvo"}) + self.assertEqual(restored.runs[state.run_id].partial_chunk_summaries, {"src/app.py": ["primeiro bloco salvo"]}) + original.client.close() + restored.client.close() + + def test_cancel_from_paused_state_is_terminal(self): + with TemporaryDirectory() as workspace: + analyzer = Analyzer(AnalyzerSettings(workspace_dir=Path(workspace))) + state = RunState(run_id="paused123", url="https://github.com/acme/api", status="paused", stage="files") + analyzer.runs[state.run_id] = state + result = analyzer.cancel(state.run_id) + self.assertEqual(result["status"], "cancelled") + self.assertFalse(result["can_resume"]) + analyzer.client.close() + + def test_service_restart_does_not_resume_an_interrupted_cancellation(self): + with TemporaryDirectory() as workspace: + settings = AnalyzerSettings(workspace_dir=Path(workspace)) + original = Analyzer(settings) + original.runs["cancel123"] = RunState( + run_id="cancel123", url="https://github.com/acme/api", status="cancelling", stage="files" + ) + original._persist_checkpoint(original.runs["cancel123"]) + restored = Analyzer(settings) + self.assertEqual(restored.status("cancel123")["status"], "cancelled") + self.assertFalse(restored.status("cancel123")["can_resume"]) + original.client.close() + restored.client.close() + + def test_pause_and_resume_reuses_completed_file_summary(self): + with TemporaryDirectory() as workspace: + settings = AnalyzerSettings(workspace_dir=Path(workspace), quick_profile_files=1, ollama_max_retries=0) + analyzer = Analyzer(settings) + state = RunState(run_id="resume123", url="https://github.com/acme/api", status="queued", profile="quick") + analyzer.runs[state.run_id] = state + + def clone(_url, destination): + (destination / "src").mkdir(parents=True, exist_ok=True) + (destination / "README.md").write_text("Projeto de teste", encoding="utf-8") + (destination / "src" / "app.py").write_text("def main():\n return 1\n", encoding="utf-8") + + analyzer._clone = clone + + class FakeClient: + def __init__(self): + self.calls = [] + self.truncation_policies = [] + self.pause_on_first_call = True + + def check(self): + return None + + def chat(self, _system, prompt, should_cancel=None, num_predict=None, accept_truncated=False): + self.calls.append(prompt) + self.truncation_policies.append(accept_truncated) + if self.pause_on_first_call: + self.pause_on_first_call = False + analyzer.pause(state.run_id) + if should_cancel: + should_cancel() + return f"resposta-{len(self.calls)}" + + def close(self): + return None + + client = FakeClient() + analyzer.client = client + analyzer._start_worker(state) + state.worker_thread.join(timeout=5) + self.assertFalse(state.worker_thread.is_alive()) + self.assertEqual(analyzer.status(state.run_id)["status"], "paused") + self.assertEqual(state.files_processed, 0) + self.assertNotIn("README.md", state.completed_summaries) + + resumed = analyzer.resume(state.run_id) + self.assertIn(resumed["status"], {"queued", "running"}) + state.worker_thread.join(timeout=5) + self.assertFalse(state.worker_thread.is_alive()) + self.assertEqual(analyzer.status(state.run_id)["status"], "completed") + self.assertEqual(len(client.calls), 3) # Refaz a chamada interrompida e depois sintetiza o projeto. + self.assertEqual(client.truncation_policies, [True, True, True]) + if __name__ == "__main__": unittest.main() diff --git a/ai-service/tests/test_ollama_client.py b/ai-service/tests/test_ollama_client.py new file mode 100644 index 0000000..6983c72 --- /dev/null +++ b/ai-service/tests/test_ollama_client.py @@ -0,0 +1,130 @@ +from copy import deepcopy +from unittest import TestCase +from unittest.mock import MagicMock, Mock, patch + +from analyzer.ollama_client import OllamaClient + + +class TestOllamaClient(TestCase): + @patch("analyzer.ollama_client.httpx.Client") + def test_chat_caps_generated_tokens(self, client_factory): + client = client_factory.return_value + response = Mock() + response.iter_lines.return_value = [ + '{"message":{"content":"resumo"},"done":true}' + ] + client.stream.return_value.__enter__.return_value = response + + ollama = OllamaClient( + "http://ollama:11434", "qwen2.5:1.5b", 300, "5m", num_predict=700 + ) + self.assertEqual(ollama.chat("sistema", "prompt"), "resumo") + + payload = client.stream.call_args.kwargs["json"] + self.assertEqual(payload["options"]["num_predict"], 700) + self.assertEqual(payload["options"]["num_ctx"], 8192) + self.assertTrue(payload["stream"]) + + @patch("analyzer.ollama_client.httpx.Client") + def test_chat_checks_cancellation_while_streaming(self, client_factory): + client = client_factory.return_value + response = Mock() + response.iter_lines.return_value = [ + '{"message":{"content":"parcial"},"done":false}', + '{"message":{"content":"resposta"},"done":true}', + ] + client.stream.return_value.__enter__.return_value = response + checks = 0 + + def cancel_after_first_chunk(): + nonlocal checks + checks += 1 + if checks == 2: + raise RuntimeError("cancelado") + + ollama = OllamaClient("http://ollama:11434", "qwen", 300, "5m") + with self.assertRaisesRegex(RuntimeError, "cancelado"): + ollama.chat("sistema", "prompt", should_cancel=cancel_after_first_chunk) + + @patch("analyzer.ollama_client.httpx.Client") + def test_chat_retries_when_model_stops_at_output_limit(self, client_factory): + client = client_factory.return_value + first_response = Mock() + first_response.iter_lines.return_value = [ + '{"message":{"content":"cortada"},"done":true,"done_reason":"length"}' + ] + second_response = Mock() + second_response.iter_lines.return_value = [ + '{"message":{"content":"completa"},"done":true,"done_reason":"stop"}' + ] + first_stream, second_stream = MagicMock(), MagicMock() + first_stream.__enter__.return_value = first_response + second_stream.__enter__.return_value = second_response + streams = [first_stream, second_stream] + payloads = [] + + def stream(*_args, **kwargs): + payloads.append(deepcopy(kwargs["json"])) + return streams.pop(0) + + client.stream.side_effect = stream + + ollama = OllamaClient("http://ollama:11434", "qwen", 300, "5m", max_retries=1, num_predict=128) + self.assertEqual(ollama.chat("sistema", "prompt"), "completa") + limits = [payload["options"]["num_predict"] for payload in payloads] + self.assertEqual(limits, [128, 256]) + + @patch("analyzer.ollama_client.httpx.Client") + def test_chat_keeps_expanding_truncated_output_until_safe_ceiling(self, client_factory): + client = client_factory.return_value + limits = [] + streams = [] + for index in range(5): + response = Mock() + if index < 4: + response.iter_lines.return_value = [ + '{"message":{"content":"parcial"},"done":true,"done_reason":"length"}' + ] + else: + response.iter_lines.return_value = [ + '{"message":{"content":"completa"},"done":true,"done_reason":"stop"}' + ] + stream = MagicMock() + stream.__enter__.return_value = response + streams.append(stream) + + def stream(*_args, **kwargs): + limits.append(kwargs["json"]["options"]["num_predict"]) + return streams.pop(0) + + client.stream.side_effect = stream + ollama = OllamaClient("http://ollama:11434", "qwen", 300, "5m", max_retries=0, num_predict=512) + + self.assertEqual(ollama.chat("sistema", "prompt"), "completa") + self.assertEqual(limits, [512, 1024, 2048, 4096, 8192]) + + @patch("analyzer.ollama_client.httpx.Client") + def test_quick_file_summary_keeps_partial_output_instead_of_retrying_slowly(self, client_factory): + client = client_factory.return_value + response = Mock() + response.iter_lines.return_value = [ + '{"message":{"content":"Resumo útil"},"done":true,"done_reason":"length"}' + ] + client.stream.return_value.__enter__.return_value = response + ollama = OllamaClient("http://ollama:11434", "qwen", 300, "5m") + + result = ollama.chat("sistema", "prompt", num_predict=512, accept_truncated=True) + + self.assertIn("Resumo útil", result) + self.assertIn("trate este trecho como parcial", result) + self.assertEqual(client.stream.call_count, 1) + + def test_chat_uses_safe_default_token_limit(self): + with patch("analyzer.ollama_client.httpx.Client"): + ollama = OllamaClient("http://ollama:11434", "qwen", 300, "5m") + self.assertEqual(ollama.num_predict, 1024) + + def test_chat_enforces_minimum_token_limit(self): + with patch("analyzer.ollama_client.httpx.Client"): + ollama = OllamaClient("http://ollama:11434", "qwen", 300, "5m", num_predict=1) + self.assertEqual(ollama.num_predict, 128) diff --git a/ai-service/tests/test_safety_limits.py b/ai-service/tests/test_safety_limits.py new file mode 100644 index 0000000..18e2a54 --- /dev/null +++ b/ai-service/tests/test_safety_limits.py @@ -0,0 +1,145 @@ +import os +import threading +import time +import subprocess +import sys +import json +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from analyzer.config import AnalyzerSettings +from analyzer.pipeline import Analyzer, AnalysisBusy, AnalysisError +from analyzer.scanner import scan_repository, read_repository_text +from services.chunker import chunk_document_text + + +class SafetyTests(unittest.TestCase): + def test_git_subprocess_enforces_memory_file_and_cpu_limits(self): + with TemporaryDirectory() as folder: + fake_git = Path(folder) / 'git' + fake_git.write_text(f'#!{sys.executable}\n' + '''import resource, json, os +memory_limited = False +try: + data = bytearray(256 * 1024**2) +except MemoryError: + memory_limited = True +file_limited = False +try: + with open(os.environ['LIMIT_TEST_FILE'], 'wb') as f: + f.write(b'x' * (2 * 1024**2)) +except OSError: + file_limited = True +print(json.dumps([memory_limited, file_limited, resource.getrlimit(resource.RLIMIT_CPU)[0]])) +''') + fake_git.chmod(0o755) + result = subprocess.run([sys.executable, '-m', 'analyzer.limited_git', '1', '128', '2'], + env={**os.environ, 'PATH': folder + os.pathsep + os.environ['PATH'], 'LIMIT_TEST_FILE': str(Path(folder) / 'data')}, + capture_output=True, text=True, timeout=5, check=True) + self.assertEqual(json.loads(result.stdout), [True, True, 2]) + self.assertLessEqual((Path(folder) / 'data').stat().st_size, 1024**2) + + def test_links_chains_directories_and_fifo_never_enter_inventory(self): + with TemporaryDirectory() as folder: + root = Path(folder) / 'repo' + root.mkdir() + (Path(folder) / 'private.txt').write_text('private') + (root / 'normal.py').write_text('print(1)') + (root / 'link.txt').symlink_to(Path(folder) / 'private.txt') + (root / 'chain.txt').symlink_to(root / 'link.txt') + (root / 'directory').symlink_to(Path(folder), target_is_directory=True) + os.mkfifo(root / 'fifo') + inventory = scan_repository(root, 1000, 100) + self.assertEqual([f.path for f in inventory['files']], ['normal.py']) + for path in ['link.txt', 'chain.txt', 'fifo', 'directory/private.txt', '../private.txt']: + with self.assertRaises((ValueError, OSError)): + read_repository_text(root, root / path, 1000) + (root / 'normal.py').unlink() + (root / 'normal.py').symlink_to(Path(folder) / 'private.txt') + with self.assertRaises(OSError): + read_repository_text(root, root / inventory['files'][0].path, 1000) + + def test_text_encodings_and_file_size(self): + with TemporaryDirectory() as folder: + root = Path(folder) + for encoding in ['utf-8', 'utf-8-sig', 'utf-16', 'utf-32']: + path = root / 'text.txt' + path.write_text('ação 😊', encoding=encoding) + self.assertEqual(read_repository_text(root, path, 100), 'ação 😊') + with self.assertRaises(ValueError): + read_repository_text(root, path, 2) + + def test_chunk_bounds_overlap_and_complete_reconstruction(self): + for text in ['x'*60000, 'á😊漢'*5000, 'a'*1000, 'abc\n\ndef'*1000, 'x'*1001]: + for overlap in [0, 150]: + chunks = chunk_document_text(text, 1000, overlap) + self.assertTrue(all(0 < len(c) <= 1000 for c in chunks)) + restored = chunks[0] + ''.join(c[overlap:] for c in chunks[1:]) + self.assertEqual(restored, text) + for size, overlap in [(0,0),(10,10),(10,-1)]: + with self.assertRaises(ValueError): + chunk_document_text('text', size, overlap) + + def test_global_concurrency_queue_and_idempotent_start(self): + with TemporaryDirectory() as folder: + analyzer = Analyzer(AnalyzerSettings(workspace_dir=Path(folder), max_active_runs=1, max_queued_runs=1)) + self.addCleanup(analyzer.client.close) + release = threading.Event() + started = threading.Event() + calls = [] + def run(run_id): + calls.append(run_id) + started.set() + release.wait(3) + analyzer._push(run_id, status='completed') + analyzer._run = run + try: + analyzer.start('https://github.com/acme/repo', run_id='first') + self.assertTrue(started.wait(1)) + analyzer.start('https://github.com/acme/repo', run_id='second') + self.assertEqual(analyzer.start('https://github.com/acme/repo', run_id='first'), 'first') + with self.assertRaises(AnalysisBusy): + analyzer.start('https://github.com/acme/repo', run_id='third') + self.assertEqual(calls, ['first']) + analyzer.cancel('second') + analyzer.runs['second'].worker_thread.join(2) + self.assertEqual(analyzer.status('second')['status'], 'cancelled') + self.assertEqual(calls, ['first']) + finally: + release.set() + analyzer.runs['first'].worker_thread.join(3) + + def test_clone_disk_limit_kills_process_and_removes_partial_clone(self): + with TemporaryDirectory() as folder: + analyzer = Analyzer(AnalyzerSettings(workspace_dir=Path(folder), max_repo_size_mb=1)) + self.addCleanup(analyzer.client.close) + destination = Path(folder) / 'runs' / 'big' / 'repo' + class Process: + pid = 12345 + def poll(self): return None + def wait(self): return 0 + def launch(*args, **kwargs): + destination.mkdir(parents=True) + (destination / 'large').write_bytes(b'x' * (1024**2 + 1)) + return Process() + with patch('analyzer.pipeline.subprocess.Popen', side_effect=launch), patch('analyzer.pipeline.os.killpg') as kill: + with self.assertRaisesRegex(AnalysisError, 'cota de disco'): + analyzer._clone('https://github.com/acme/big', destination) + kill.assert_called_once() + self.assertFalse(destination.exists()) + + def test_checkpoint_without_completed_clone_reclones_before_scan(self): + with TemporaryDirectory() as folder: + from analyzer.models import RunState + analyzer = Analyzer(AnalyzerSettings(workspace_dir=Path(folder))) + self.addCleanup(analyzer.client.close) + state = RunState(run_id='interrupted', url='https://github.com/acme/repo', status='paused', stage='clone') + analyzer.runs[state.run_id] = state + repo = Path(folder) / 'runs' / state.run_id / 'repo' + repo.mkdir(parents=True) + (repo / 'incomplete.txt').write_text('incomplete') + with patch.object(analyzer.client, 'check'), patch.object(analyzer, '_clone', side_effect=AnalysisError('reclone')) as clone: + analyzer._run(state.run_id) + clone.assert_called_once() + self.assertEqual(state.status, 'failed') diff --git a/backend/README.md b/backend/README.md index 563c84d..ae78869 100644 --- a/backend/README.md +++ b/backend/README.md @@ -53,3 +53,15 @@ PO/admin podem consultar todos os projetos. Desenvolvedores precisam de alocaç Defina `AI_SERVICE_TOKEN` com um segredo aleatório em `.env`. O Compose exige a configuração e não publica a porta do Python. O workflow n8n deve ser atualizado para encaminhar `X-Service-Token`. Não versione esse segredo. O chat somente apresenta IDs explicitamente citados que pertencem ao contexto recuperado. Respostas sem citações válidas usam o fallback textual; essa validação não comprova semanticamente cada afirmação. + +## Fila de análise de repositórios + +A migration 016 registra a solicitação antes de chamar o Python. O worker usa lease e um run_id estável; perda de resposta HTTP repete a solicitação sem iniciar outro trabalho. Clientes podem enviar Idempotency-Key (UUID) e devem reutilizá-la no retry do mesmo formulário. Retomadas também são enfileiradas sob o bloqueio do projeto. Nenhuma chamada HTTP mantém transação aberta. + +Há no máximo uma análise não finalizada por projeto e três por usuário. O arquivamento pede que a análise seja concluída ou cancelada primeiro; solicitação e arquivamento usam o mesmo lock. Cancelar antes do primeiro despacho dispensa o Python. Depois de um despacho incerto, o cancelamento é durável e será entregue quando o serviço voltar. + +Conclusão e disponibilidade do relatório são separadas: análises concluídas sem relatório continuam sendo sincronizadas pelo worker e pela tela. Execução inexistente no Python torna-se falha explícita. O volume repo_analysis_data preserva checkpoints; um clone sem marcador de conclusão é refeito, descartando resumos incompatíveis. + +O Analyzer no container Linux usa abertura de arquivos sem seguir links (inclusive diretórios intermediários), aceita apenas arquivos regulares e mantém o suporte a UTF-8/16/32. Há dois workers ativos e seis posições de espera por processo Python. Execute uma única instância de Uvicorn por volume de workspace. + +O clone tem limites de memória virtual, CPU, tamanho por arquivo e duração; seu grupo de processos é encerrado em cancelamento ou excesso. O espaço por repositório e pelo workspace é monitorado a cada 100 ms durante o clone: pode haver excesso transitório entre medições, portanto isso não substitui uma quota de filesystem para um teto estrito de bytes. Compose limita o serviço Python a 2 CPUs, 1 GiB e 128 processos. Ollama é um serviço separado. Os valores de fila/clone estão em .env.example. diff --git a/backend/scripts/demo-s201.mts b/backend/scripts/demo-s201.mts new file mode 100644 index 0000000..26790c6 --- /dev/null +++ b/backend/scripts/demo-s201.mts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve, sep } from "node:path"; +import { Client } from "pg"; +import { applyUntil } from "../src/database/migration-test-utils.js"; +const name="sinapse-s201-demo-"+randomUUID().slice(0,8), password=randomUUID(), evidence:string[]=[]; +function docker(args:string[]){const r=spawnSync("docker",args,{encoding:"utf8"});if(r.status!==0)throw Error("Operação Docker falhou.");return r.stdout.trim();} +const record=(s:string)=>{console.log(s);evidence.push(s);}; +let started=false, server:import("node:http").Server|undefined, db:import("pg").Pool|undefined; +const folder=await mkdtemp(join(tmpdir(),"sinapse-s201-demo-")); +try { + const ai=JSON.parse(docker(["inspect","sinapse-ai-service"]))[0]; + const token=ai.Config.Env.find((s:string)=>s.startsWith("AI_SERVICE_TOKEN=")); + assert.ok(token); + process.env.AI_SERVICE_TOKEN=token.slice(17); + process.env.DOCUMENT_STORAGE_DIR=folder; + process.env.DOCUMENT_INGEST_WEBHOOK_URL="http://localhost:5678/webhook/sinapse-ingest"; + process.env.NODE_ENV="test"; + docker(["run","-d","--name",name,"-e","POSTGRES_USER=sinapse","-e","POSTGRES_PASSWORD="+password,"-e","POSTGRES_DB=sinapse_s201_demo_test","-p","127.0.0.1::5432","pgvector/pgvector:pg16"]);started=true; + const port=JSON.parse(docker(["inspect",name]))[0].NetworkSettings.Ports["5432/tcp"][0].HostPort; + process.env.DATABASE_URL="postgresql://sinapse:"+password+"@127.0.0.1:"+port+"/sinapse_s201_demo_test"; + let ready=false; + for(let i=0;i<40;i++){if(spawnSync("docker",["exec",name,"pg_isready","-U","sinapse","-d","sinapse_s201_demo_test"],{encoding:"utf8"}).status===0){ready=true;break;}await new Promise(r=>setTimeout(r,500));} + assert.ok(ready); + const client=new Client({connectionString:process.env.DATABASE_URL});await client.connect(); + try{await applyUntil(client,true,()=>false);}finally{await client.end();} + const {pool}=await import("../src/database/db.js");db=pool; + const {hashPassword}=await import("../src/modules/auth/pssword.service.js"); + const {app}=await import("../src/app.js"); + const {DocumentIngestionWorker}=await import("../src/modules/documents/documents.ingestion.js"); + const {env}=await import("../src/config/env.js"); + const loginPassword=randomUUID(); + await pool.query("INSERT INTO usuario(id,nome,email,senha_hash,role) VALUES($1,'PO Demonstração','demo-s201@example.test',$2,'admin')",[randomUUID(),await hashPassword(loginPassword)]); + server=app.listen(0,"127.0.0.1");await new Promise(r=>server!.once("listening",r)); + const base="http://127.0.0.1:"+(server.address() as import("node:net").AddressInfo).port; + let authorization=""; + async function api(path:string,method="GET",body?:string|Buffer,headers:Record={},expected=200){ + const r=await fetch(base+path,{method,headers:{...(authorization?{Authorization:authorization}:{}),...headers},body}); + const data=await r.json();assert.equal(r.status,expected,method+" "+path);return data; + } + const login=await api("/api/v1/auth/login","POST",JSON.stringify({email:"demo-s201@example.test",password:loginPassword}),{"Content-Type":"application/json"}); + authorization="Bearer "+login.token; + const project=await api("/api/v1/projects","POST",JSON.stringify({nome:"Demonstração S2-01",cliente:"Dados sintéticos",status:"ativo"}),{"Content-Type":"application/json"},201); + assert.ok(project.id); + const path="/api/v1/projects/"+project.id+"/documents", worker=new DocumentIngestionWorker(); + const lookup=async(id:string)=>(await api(path)).items.find((d:{id:string})=>d.id===id); + const t=Date.now(); + const doc=await api(path,"POST",Buffer.from("A política S201ALFA estabelece revisão de requisitos antes da entrega. Somente dados sintéticos."),{"Content-Type":"application/octet-stream","X-File-Name":"demonstracao.txt"},201); + assert.equal(doc.status_processamento,"pendente");record("Upload HTTP 201 em "+(Date.now()-t)+" ms; estado pendente."); + const serviceToken=env.AI_SERVICE_TOKEN;env.AI_SERVICE_TOKEN="demonstracao-token-invalido"; + try{await worker.tick(project.id);}finally{env.AI_SERVICE_TOKEN=serviceToken;} + const failed=await lookup(doc.id);assert.equal(failed.status_processamento,"falha");assert.equal(failed.erro_processamento_codigo,"SERVICE_HTTP_ERROR"); + record("Falha controlada no caminho backend → n8n → IA real: token inválido recusado; motivo seguro: "+failed.erro_processamento); + const retryPath=path+"/"+doc.id+"/reprocess"; + await api(retryPath,"POST",undefined,{},202); + const before=(await pool.query("SELECT ingest_attempts,ingest_next_attempt_at,updated_at FROM documento WHERE id=$1",[doc.id])).rows; + await api(retryPath,"POST",undefined,{},202); + assert.deepEqual((await pool.query("SELECT ingest_attempts,ingest_next_attempt_at,updated_at FROM documento WHERE id=$1",[doc.id])).rows,before); + record("Dois pedidos de retry HTTP 202 preservaram fila, contadores e timestamps."); + const processing=worker.tick(project.id);let observed=false; + for(let i=0;i<60;i++){if((await lookup(doc.id)).status_processamento==="processando"){observed=true;break;}await new Promise(r=>setTimeout(r,50));} + assert.ok(observed);record("Estado processando observado pela API durante execução real."); + await api("/api/v1/projects");record("Consulta de projetos HTTP 200 durante processamento."); + await processing; + const done=await lookup(doc.id);assert.equal(done.status_processamento,"processado");assert.equal(done.erro_processamento,null); + const chunks=await pool.query("SELECT projeto_id,vector_dims(embedding) n,metadados_json FROM chunk WHERE entidade_id=$1",[doc.id]); + assert.ok(chunks.rowCount); + for(const row of chunks.rows){assert.equal(row.projeto_id,project.id);assert.equal(row.n,1024);assert.equal(row.metadados_json.project_id,project.id);} + const search=await api("/api/v1/search?projeto_id="+project.id+"&q=S201ALFA"); + assert.ok(search.items.some((s:{entidade_id:string})=>s.entidade_id===doc.id)); + record("Estado processado; "+chunks.rowCount+" trecho(s), embeddings reais de 1024 dimensões, origem preservada e documento encontrado na busca."); + const other=await api("/api/v1/projects","POST",JSON.stringify({nome:"Projeto isolado",cliente:"Dados sintéticos"}),{"Content-Type":"application/json"},201); + assert.equal((await api("/api/v1/search?projeto_id="+other.id+"&q=S201ALFA")).items.length,0); + record("Busca no segundo projeto não retornou dados do primeiro."); + const pdf=await api(path,"POST",Buffer.from("%PDF-1.7\nDocumento demonstrativo truncado."),{"Content-Type":"application/octet-stream","X-File-Name":"corrompido.pdf"},201); + await worker.tick(project.id); + const extraction=await lookup(pdf.id);assert.equal(extraction.status_processamento,"falha");assert.equal(extraction.erro_processamento_codigo,"INVALID_DOCUMENT");assert.equal(extraction.nova_tentativa_pendente,false); + record("PDF truncado falhou na extração da IA real: INVALID_DOCUMENT; motivo: "+extraction.erro_processamento); + await api(path+"/"+pdf.id+"/reprocess","POST",undefined,{},202);assert.equal((await lookup(pdf.id)).status_processamento,"pendente"); + record("Falha permanente de extração permite nova tentativa manual HTTP 202."); + await writeFile(resolve("../docs/S2-01-DEMONSTRACAO.md"),"# Demonstração integrada S2-01\n\nData: "+new Date().toLocaleString("pt-BR",{timeZone:"America/Sao_Paulo"})+" (America/Sao_Paulo).\n\nBackend com código local, PostgreSQL 16/pgvector temporário, n8n e IA/Ollama reais. Migrações aplicadas, dados sintéticos, autenticação e chamadas HTTP reais. Worker acionado por tick explícito para controlar a demonstração; agendamento periódico não medido.\n\n"+evidence.map(s=>"- "+s).join("\n")+"\n\nAsserções aprovadas. Banco e arquivos temporários removidos ao terminar; dados de desenvolvimento preservados. Sem credenciais no relatório.\n\nReproduzir no backend com serviços locais ativos: node --import tsx scripts/demo-s201.mts.\n"); + record("Relatório salvo em docs/S2-01-DEMONSTRACAO.md."); +} finally { + if(server){server.closeAllConnections();await new Promise(r=>server!.close(()=>r()));} + if(db)await db.end(); + assert.ok(resolve(folder).startsWith(resolve(tmpdir())+sep));await rm(folder,{recursive:true,force:true}); + if(started)docker(["rm","-f","-v",name]);console.log("Ambiente temporário removido."); +} diff --git a/backend/scripts/validate-s1.mts b/backend/scripts/validate-s1.mts index 9476671..ad31e80 100644 --- a/backend/scripts/validate-s1.mts +++ b/backend/scripts/validate-s1.mts @@ -5,12 +5,16 @@ import { once } from "node:events"; import { app } from "../src/index.js"; import { pool } from "../src/database/db.js"; import { env } from "../src/config/env.js"; +import { assertValidationDatabase } from "../src/database/validation-target.js"; import { hashPassword } from "../src/modules/auth/pssword.service.js"; -import { apiRequest, readUser } from "../../frontend/src/auth/api.ts"; -import { createProject, getProject, listProjects } from "../../frontend/src/projects/api.ts"; +import { apiRequest, readUser } from "../../frontend/src/api/api_auth.ts"; +import { createProject, getProject, listProjects } from "../../frontend/src/api/api_projects.ts"; assert.equal(env.NODE_ENV, "test", "Execute com NODE_ENV=test"); -assert.match(env.POSTGRES_DB, /_s1_validation$/, "Use um banco descartável terminado em _s1_validation"); +// Check the connected server, including DATABASE_URL and PostgreSQL defaults. +const target = await pool.query('SELECT current_database() AS database'); +try { assertValidationDatabase(target.rows[0]?.database); } +catch (error) { await pool.end(); throw error; } const server = app.listen(0, "127.0.0.1"); await once(server, "listening"); const address = server.address(); diff --git a/backend/src/database/db.test.ts b/backend/src/database/db.test.ts new file mode 100644 index 0000000..a63d19c --- /dev/null +++ b/backend/src/database/db.test.ts @@ -0,0 +1,16 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Pool } from 'pg'; +import { checkDatabaseConnection } from './db'; + +test('healthcheck devolve a conexão no sucesso e descarta no erro de consulta', async () => { + for (const failed of [false, true]) { + const releases: boolean[] = []; + const db = { connect: async () => ({ + query: async () => { if (failed) throw new Error('query failed'); }, + release: (discard: boolean) => releases.push(discard), + }) } as unknown as Pool; + assert.equal(await checkDatabaseConnection(db), !failed); + assert.deepEqual(releases, [failed]); + } +}); diff --git a/backend/src/database/db.ts b/backend/src/database/db.ts index afecef3..dd5f9fc 100644 --- a/backend/src/database/db.ts +++ b/backend/src/database/db.ts @@ -1,25 +1,21 @@ -import { Pool } from "pg"; +import { Pool, type PoolClient } from "pg"; import { env } from "../config/env.js"; +import { databasePoolConfig } from './pool-config.js'; -export const pool = new Pool({ - host: env.POSTGRES_HOST, - port: env.POSTGRES_PORT, - user: env.POSTGRES_USER, - password: env.POSTGRES_PASSWORD, - database: env.POSTGRES_DB, - max: 20, - idleTimeoutMillis: 30000, - connectionTimeoutMillis: 2000, -}); +export const pool = new Pool(databasePoolConfig(env)); -export async function checkDatabaseConnection(): Promise { +export async function checkDatabaseConnection(db: Pick = pool): Promise { + let client: PoolClient | undefined; + let failed = false; try { - const client = await pool.connect(); + client = await db.connect(); await client.query("SELECT 1"); - client.release(); return true; } catch (error) { + failed = true; console.error("[Database] Failed to connect to PostgreSQL:", error); return false; + } finally { + client?.release(failed); } } diff --git a/backend/src/database/pool-config.ts b/backend/src/database/pool-config.ts new file mode 100644 index 0000000..06842e5 --- /dev/null +++ b/backend/src/database/pool-config.ts @@ -0,0 +1,25 @@ +import type { PoolConfig } from 'pg'; + +interface DatabaseEnvironment { + DATABASE_URL?: string; + POSTGRES_HOST: string; + POSTGRES_PORT: number; + POSTGRES_USER: string; + POSTGRES_PASSWORD: string; + POSTGRES_DB: string; +} + +export function databasePoolConfig(env: DatabaseEnvironment): PoolConfig { + return { + ...(env.DATABASE_URL ? { connectionString: env.DATABASE_URL } : { + host: env.POSTGRES_HOST, + port: env.POSTGRES_PORT, + user: env.POSTGRES_USER, + password: env.POSTGRES_PASSWORD, + database: env.POSTGRES_DB, + }), + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, + }; +} diff --git a/backend/src/database/validation-target.test.ts b/backend/src/database/validation-target.test.ts new file mode 100644 index 0000000..015849b --- /dev/null +++ b/backend/src/database/validation-target.test.ts @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { assertValidationDatabase } from './validation-target'; +import { databasePoolConfig } from './pool-config'; + +test('validação recusa o banco efetivo mesmo com POSTGRES_DB de teste', () => { + const config = databasePoolConfig({ DATABASE_URL: 'postgresql://localhost/production', + POSTGRES_DB: 'sinapse_s1_validation', POSTGRES_HOST: 'localhost', POSTGRES_PORT: 5432, + POSTGRES_USER: 'test', POSTGRES_PASSWORD: 'test' }); + assert.equal(config.connectionString, 'postgresql://localhost/production'); + assert.equal(config.database, undefined); + assert.throws(() => assertValidationDatabase('production')); + assert.throws(() => assertValidationDatabase(undefined)); + assert.doesNotThrow(() => assertValidationDatabase('sinapse_s1_validation')); +}); diff --git a/backend/src/database/validation-target.ts b/backend/src/database/validation-target.ts new file mode 100644 index 0000000..986d6ec --- /dev/null +++ b/backend/src/database/validation-target.ts @@ -0,0 +1,5 @@ +export function assertValidationDatabase(database: unknown): void { + if (typeof database !== 'string' || !/^[a-zA-Z0-9_-]+_s1_validation$/.test(database)) { + throw new Error('Use um banco descartável terminado em _s1_validation'); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index d662105..3a0f668 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,5 +1,6 @@ import { startDocumentIngestionWorker } from "./modules/documents/documents.ingestion.js"; import app from "./app.js"; +import { startRepoAnalysesWorker } from "./modules/repo-analyses/repo-analyses.service.js"; import { env } from "./config/env.js"; import { startDocumentsBackgroundWorker } from "./modules/documents/documents.service.js"; @@ -9,6 +10,7 @@ const PORT = env.PORT; if (env.NODE_ENV !== "test") { startDocumentsBackgroundWorker(); startDocumentIngestionWorker(); + startRepoAnalysesWorker(); if (!env.DOCUMENT_EVENTS_WEBHOOK_URL?.trim()) { console.warn("[Documents] DOCUMENT_EVENTS_WEBHOOK_URL is not configured; removal events will retry until a consumer is configured."); } diff --git a/backend/src/modules/auth/auth.repository.ts b/backend/src/modules/auth/auth.repository.ts index 975b185..7f9f379 100644 --- a/backend/src/modules/auth/auth.repository.ts +++ b/backend/src/modules/auth/auth.repository.ts @@ -42,7 +42,7 @@ export class AuthRepository { created_at, updated_at `, - [data.nome.trim(), data.email.trim().toLowerCase(), data.senha_hash, data.role ?? "po"], + [data.nome.trim(), data.email.trim().toLowerCase(), data.senha_hash, data.role ?? "dev"], ); return result.rows[0]; @@ -199,4 +199,4 @@ export class AuthRepository { } } -export const authRepository = new AuthRepository(); \ No newline at end of file +export const authRepository = new AuthRepository(); diff --git a/backend/src/modules/documents/documents.ingestion.db.test.ts b/backend/src/modules/documents/documents.ingestion.db.test.ts index fb39011..fb0f545 100644 --- a/backend/src/modules/documents/documents.ingestion.db.test.ts +++ b/backend/src/modules/documents/documents.ingestion.db.test.ts @@ -45,7 +45,11 @@ test("ingestão: falha, retry, lease, vetores e remoção sem ressuscitar docume assert.equal(failed.nova_tentativa_pendente,true); assert.equal(failed.erro_processamento_codigo,"SERVICE_HTTP_ERROR"); assert.equal(failed.tentativas_processamento,1); + assert.match(failed.erro_processamento!, /recusou/); await repository.retry(project,id); + await db.query("UPDATE documento SET ingest_attempts=2 WHERE id=$1", [id]); + await repository.retry(project,id); + assert.equal((await db.query("SELECT ingest_attempts FROM documento WHERE id=$1", [id])).rows[0].ingest_attempts, 2); const job=(await repository.claim(project))!; assert.equal(await repository.claim(project),null); await assert.rejects(repository.finish(job,[{text:"Inválido",embedding:[1]}])); diff --git a/backend/src/modules/documents/documents.ingestion.ts b/backend/src/modules/documents/documents.ingestion.ts index f790c6d..f7389f0 100644 --- a/backend/src/modules/documents/documents.ingestion.ts +++ b/backend/src/modules/documents/documents.ingestion.ts @@ -10,104 +10,147 @@ import { ConflictError, NotFoundError } from "../../shared/errors.js"; import { assertWritable, lockHierarchy } from "../projects/hierarchy-archive.js"; import { LocalDocumentStorage } from "./documents.storage.js"; -export interface IngestionFailure { code: string; retryable: boolean; httpStatus?: number } -class IngestionScopeError extends Error {} +export interface IngestionFailure { + code: string; + retryable: boolean; + httpStatus?: number; +} +export function ingestionFailureReason(code: string): string { + const reasons: Record = { + INVALID_SCOPE: "O serviço retornou um documento ou projeto diferente do solicitado.", + INVALID_RESPONSE: "O serviço retornou dados de indexação inválidos.", + INVALID_DOCUMENT: "Não foi possível extrair o conteúdo do documento.", + TIMEOUT: "O serviço de processamento excedeu o tempo limite.", + SERVICE_UNAVAILABLE: "O serviço de processamento está indisponível.", + SERVICE_HTTP_ERROR: "O serviço de processamento recusou a solicitação.", + FILE_NOT_FOUND: "O arquivo armazenado não foi encontrado.", + }; + return reasons[code] ?? "Não foi possível processar o documento."; +} +class IngestionScopeError extends Error { +} export function classifyIngestionFailure(error: unknown): IngestionFailure { - if (error instanceof IngestionScopeError) return {code: "INVALID_SCOPE", retryable: false}; - if (error instanceof z.ZodError) return {code: "INVALID_RESPONSE", retryable: false}; + if (error instanceof IngestionScopeError) + return { code: "INVALID_SCOPE", retryable: false }; + if (error instanceof z.ZodError) + return { code: "INVALID_RESPONSE", retryable: false }; if (axios.isAxiosError(error)) { - if (["ECONNABORTED","ETIMEDOUT"].includes(error.code ?? "")) return {code: "TIMEOUT", retryable: true}; + if (["ECONNABORTED", "ETIMEDOUT"].includes(error.code ?? "")) + return { code: "TIMEOUT", retryable: true }; const status = error.response?.status; - if (status) return {code: status === 422 || status === 400 ? "INVALID_DOCUMENT" : "SERVICE_HTTP_ERROR", retryable: status >= 500 || status === 429, httpStatus: status}; - return {code: "SERVICE_UNAVAILABLE", retryable: true}; + if (status) + return { code: status === 422 || status === 400 ? "INVALID_DOCUMENT" : "SERVICE_HTTP_ERROR", retryable: status >= 500 || status === 429, httpStatus: status }; + return { code: "SERVICE_UNAVAILABLE", retryable: true }; } - if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return {code: "FILE_NOT_FOUND", retryable: false}; - return {code: "PROCESSING_ERROR", retryable: true}; + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") + return { code: "FILE_NOT_FOUND", retryable: false }; + return { code: "PROCESSING_ERROR", retryable: true }; +} +interface IngestionJob { + id: string; + projeto_id: string; + caminho: string; + nome: string; + ingest_lease: string; } - -interface IngestionJob { id: string; projeto_id: string; caminho: string; nome: string; ingest_lease: string } const chunksSchema = z.array(z.object({ text: z.string().trim().min(1).max(50000), embedding: z.array(z.number().finite()).length(1024).refine(v => v.some(n => n !== 0)) })).min(1).max(5000); export type IngestedChunk = z.infer[number]; - export class DocumentIngestionRepository { constructor(private readonly db: Pool = pool) { } - async claim(projectId?: string): Promise { - await this.db.query("UPDATE documento SET status_processamento='falha',ingest_lease=NULL,ingest_locked_until=NULL WHERE status_processamento='processando' AND ingest_attempts>=5 AND ingest_locked_until<=CURRENT_TIMESTAMP"); - const result = await this.db.query( - `WITH picked AS ( - SELECT d.id FROM documento d JOIN projeto p ON p.id=d.projeto_id - WHERE d.status_processamento <> 'processado' AND p.status <> 'arquivado' AND ($2::uuid IS NULL OR d.projeto_id=$2) - AND d.ingest_retryable AND d.ingest_attempts < 5 AND d.ingest_next_attempt_at <= CURRENT_TIMESTAMP - AND (d.ingest_locked_until IS NULL OR d.ingest_locked_until <= CURRENT_TIMESTAMP) - AND NOT EXISTS (SELECT 1 FROM documento_operacao_armazenamento op WHERE op.documento_id=d.id AND op.acao='finalizar_upload' AND op.status='pendente') - ORDER BY d.ingest_next_attempt_at,d.id LIMIT 1 FOR UPDATE OF d SKIP LOCKED - ) UPDATE documento d SET status_processamento='processando', ingest_lease=$1, - ingest_locked_until=CURRENT_TIMESTAMP + INTERVAL '3 minutes', ingest_attempts=ingest_attempts+1 - FROM picked WHERE d.id=picked.id RETURNING d.id,d.projeto_id,d.caminho,d.nome,d.ingest_lease`, [randomUUID(), projectId ?? null]); + await this.db.query("UPDATE documento SET status_processamento='falha',ingest_lease=NULL,ingest_locked_until=NULL,ingest_error='O processamento excedeu o limite de tentativas.',ingest_error_code='ATTEMPTS_EXHAUSTED',ingest_retryable=FALSE,updated_at=CURRENT_TIMESTAMP WHERE status_processamento='processando' AND ingest_attempts>=5 AND ingest_locked_until<=CURRENT_TIMESTAMP"); + const result = await this.db.query(`WITH picked AS ( + SELECT d.id FROM documento d JOIN projeto p ON p.id=d.projeto_id + WHERE d.status_processamento <> 'processado' AND p.status <> 'arquivado' AND ($2::uuid IS NULL OR d.projeto_id=$2) + AND d.ingest_retryable AND d.ingest_attempts < 5 AND d.ingest_next_attempt_at <= CURRENT_TIMESTAMP + AND (d.ingest_locked_until IS NULL OR d.ingest_locked_until <= CURRENT_TIMESTAMP) + AND NOT EXISTS (SELECT 1 FROM documento_operacao_armazenamento op WHERE op.documento_id=d.id AND op.acao='finalizar_upload' AND op.status='pendente') + ORDER BY d.ingest_next_attempt_at,d.id LIMIT 1 FOR UPDATE OF d SKIP LOCKED + ) UPDATE documento d SET status_processamento='processando', ingest_lease=$1, + ingest_locked_until=CURRENT_TIMESTAMP + INTERVAL '3 minutes', ingest_attempts=ingest_attempts+1,updated_at=CURRENT_TIMESTAMP + FROM picked WHERE d.id=picked.id RETURNING d.id,d.projeto_id,d.caminho,d.nome,d.ingest_lease`, [randomUUID(), projectId ?? null]); return result.rows[0] ?? null; } - async finish(job: IngestionJob, chunks: IngestedChunk[]): Promise { const validated = chunksSchema.parse(chunks); - await withTransaction(this.db, async client => { + await withTransaction(this.db, async (client) => { await lockHierarchy(client, "projeto", job.projeto_id); await assertWritable(client, "projeto", job.projeto_id); const current = await client.query("SELECT id FROM documento WHERE id=$1 AND projeto_id=$2 AND ingest_lease=$3 FOR UPDATE", [job.id, job.projeto_id, job.ingest_lease]); - // A removed document or superseded lease must never be resurrected. - if (!current.rowCount) return; + // Ignora respostas de documentos removidos ou tentativas substituídas. + if (!current.rowCount) + return; await client.query("DELETE FROM chunk WHERE projeto_id=$1 AND entidade_tipo='documento' AND entidade_id=$2", [job.projeto_id, job.id]); for (const [index, chunk] of validated.entries()) { await client.query(`INSERT INTO chunk(id,projeto_id,entidade_tipo,entidade_id,texto,embedding,metadados_json) - VALUES (md5($1::text || ':' || $2::text)::uuid,$3,'documento',$1::uuid,$4,$5::vector,$6::jsonb)`, - [job.id, index, job.projeto_id, chunk.text, JSON.stringify(chunk.embedding), JSON.stringify({ document_id: job.id, project_id: job.projeto_id, chunk_index: index, nome: job.nome })]); + VALUES (md5($1::text || ':' || $2::text)::uuid,$3,'documento',$1::uuid,$4,$5::vector,$6::jsonb)`, [job.id, index, job.projeto_id, chunk.text, JSON.stringify(chunk.embedding), JSON.stringify({ document_id: job.id, project_id: job.projeto_id, chunk_index: index, nome: job.nome })]); } await client.query("UPDATE documento SET status_processamento='processado',ingest_lease=NULL,ingest_locked_until=NULL,ingest_error=NULL,ingest_error_code=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=$1", [job.id]); }); } - async fail(job: IngestionJob, failure: IngestionFailure = { code: "UNKNOWN", retryable: true }): Promise { const result = await this.db.query(`UPDATE documento SET status_processamento='falha',ingest_lease=NULL,ingest_locked_until=NULL, - ingest_error='Não foi possível processar o documento.', ingest_error_code=$3,ingest_retryable=$4, ingest_next_attempt_at=CURRENT_TIMESTAMP + make_interval(secs => LEAST(3600,15 * power(2,ingest_attempts))::int),updated_at=CURRENT_TIMESTAMP - WHERE id=$1 AND ingest_lease=$2 RETURNING ingest_attempts`, [job.id, job.ingest_lease, failure.code, failure.retryable]); - if (result.rowCount) console.warn("[Documents] Ingestion failure", { documentId: job.id, projectId: job.projeto_id, attempt: result.rows[0].ingest_attempts, code: failure.code, retryable: failure.retryable, httpStatus: failure.httpStatus }); + ingest_error=$5, ingest_error_code=$3,ingest_retryable=$4, ingest_next_attempt_at=CURRENT_TIMESTAMP + make_interval(secs => LEAST(3600,15 * power(2,ingest_attempts))::int),updated_at=CURRENT_TIMESTAMP + WHERE id=$1 AND projeto_id=$6 AND ingest_lease=$2 RETURNING ingest_attempts`, [job.id, job.ingest_lease, failure.code, failure.retryable, ingestionFailureReason(failure.code), job.projeto_id]); + if (result.rowCount) + console.warn("[Documents] Ingestion failure", { documentId: job.id, projectId: job.projeto_id, attempt: result.rows[0].ingest_attempts, code: failure.code, retryable: failure.retryable, httpStatus: failure.httpStatus }); } - async retry(projectId: string, documentId: string): Promise { - await withTransaction(this.db, async client => { + await withTransaction(this.db, async (client) => { await lockHierarchy(client, "projeto", projectId); await assertWritable(client, "projeto", projectId); - const row = (await client.query("SELECT status_processamento,ingest_locked_until FROM documento WHERE id=$1 AND projeto_id=$2 FOR UPDATE", [documentId, projectId])).rows[0]; - if (!row) throw new NotFoundError("Documento não encontrado."); - if (row.ingest_locked_until && new Date(row.ingest_locked_until).getTime() > Date.now()) throw new ConflictError("Documento em processamento."); - await client.query("UPDATE documento SET status_processamento='pendente',ingest_attempts=0,ingest_lease=NULL,ingest_locked_until=NULL,ingest_next_attempt_at=CURRENT_TIMESTAMP,ingest_error=NULL,ingest_error_code=NULL,ingest_retryable=TRUE WHERE id=$1", [documentId]); + const result = await client.query( + "SELECT status_processamento,ingest_locked_until FROM documento WHERE id=$1 AND projeto_id=$2 FOR UPDATE", + [documentId, projectId], + ); + const row = result.rows[0]; + if (!row) + throw new NotFoundError("Documento não encontrado."); + // Já está na fila; não reinicia a tentativa. + if (row.status_processamento === "pendente") + return; + if (row.ingest_locked_until && new Date(row.ingest_locked_until).getTime() > Date.now()) + throw new ConflictError("Documento em processamento."); + await client.query("UPDATE documento SET status_processamento='pendente',ingest_attempts=0,ingest_lease=NULL,ingest_locked_until=NULL,ingest_next_attempt_at=CURRENT_TIMESTAMP,ingest_error=NULL,ingest_error_code=NULL,ingest_retryable=TRUE,updated_at=CURRENT_TIMESTAMP WHERE id=$1 AND projeto_id=$2", [documentId, projectId]); }); } } - export class DocumentIngestionWorker { - constructor( - private readonly repository = new DocumentIngestionRepository(), - private readonly storage = new LocalDocumentStorage(env.DOCUMENT_STORAGE_DIR), - private readonly endpoint = env.DOCUMENT_INGEST_WEBHOOK_URL || `${env.AI_SERVICE_URL}/ingest/file`, - ) { } - + constructor(private readonly repository = new DocumentIngestionRepository(), private readonly storage = new LocalDocumentStorage(env.DOCUMENT_STORAGE_DIR), private readonly endpoint = env.DOCUMENT_INGEST_WEBHOOK_URL || `${env.AI_SERVICE_URL}/ingest/file`) { } async tick(projectId?: string): Promise { const job = await this.repository.claim(projectId); - if (!job) return; + if (!job) + return; try { const content = await this.storage.read(job.caminho); const { data } = await axios.post(this.endpoint, { document_id: job.id, project_id: job.projeto_id, file_name: job.nome, content_base64: content.toString("base64") }, { headers: serviceHeaders(), timeout: 120000, maxBodyLength: 150 * 1024 * 1024, maxContentLength: 64 * 1024 * 1024 }); - if (data.document_id !== job.id || data.project_id !== job.projeto_id) throw new IngestionScopeError(); + if (!data || data.document_id !== job.id || data.project_id !== job.projeto_id) + throw new IngestionScopeError(); await this.repository.finish(job, chunksSchema.parse(data.chunks)); - } catch (error) { await this.repository.fail(job, classifyIngestionFailure(error)); } + } + catch (error) { + await this.repository.fail(job, classifyIngestionFailure(error)); + } } } - export function startDocumentIngestionWorker(worker = new DocumentIngestionWorker()): () => void { let running = false; - const tick = async () => { if (running) return; running = true; try { await worker.tick(); } catch { console.warn("[Documents] Ingestion remains queued for retry."); } finally { running = false; } }; + + async function tick() { + if (running) return; + + running = true; + try { + await worker.tick(); + } catch { + console.warn("[Documents] Ingestion remains queued for retry."); + } finally { + running = false; + } + } + const timer = setInterval(() => void tick(), 15000); - timer.unref(); void tick(); + timer.unref(); + void tick(); return () => clearInterval(timer); } diff --git a/backend/src/modules/documents/documents.repository.ts b/backend/src/modules/documents/documents.repository.ts index 9827873..d0ebd7b 100644 --- a/backend/src/modules/documents/documents.repository.ts +++ b/backend/src/modules/documents/documents.repository.ts @@ -37,7 +37,7 @@ export interface DocumentPage { const SELECT_COLUMNS = ` d.id, d.projeto_id, d.nome, d.extensao, d.mime, d.tamanho_bytes, d.status_processamento, - d.ingest_attempts AS tentativas_processamento, d.ingest_error_code AS erro_processamento_codigo, + d.ingest_error AS erro_processamento, d.ingest_attempts AS tentativas_processamento, d.ingest_error_code AS erro_processamento_codigo, (d.status_processamento <> 'processado' AND d.ingest_retryable AND d.ingest_attempts < 5 AND EXISTS (SELECT 1 FROM projeto processing_project WHERE processing_project.id=d.projeto_id AND processing_project.status <> 'arquivado')) AS nova_tentativa_pendente, d.ingest_next_attempt_at::text AS proxima_tentativa_em, diff --git a/backend/src/modules/documents/documents.retry.test.ts b/backend/src/modules/documents/documents.retry.test.ts new file mode 100644 index 0000000..9752948 --- /dev/null +++ b/backend/src/modules/documents/documents.retry.test.ts @@ -0,0 +1,51 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { Pool } from "pg"; +import { DocumentIngestionRepository, ingestionFailureReason } from "./documents.ingestion.js"; + +function createRepository(status = "falha", lockedUntil: Date | null = null) { + const state = { status, updates: 0, rollback: false }; + const client = { + async query(sql: string, values?: unknown[]) { + if (sql === "ROLLBACK") + state.rollback = true; + if (sql.includes("AS archived")) + return { rows: [{ archived: false }] }; + if (sql.startsWith("SELECT status_processamento")) { + assert.deepEqual(values, ["document", "project"]); + return { rows: [{ status_processamento: state.status, ingest_locked_until: lockedUntil }] }; + } + if (sql.startsWith("UPDATE documento")) { + assert.deepEqual(values, ["document", "project"]); + assert.ok(sql.includes("projeto_id=$2")); + state.status = "pendente"; + state.updates++; + } + return { rows: [] }; + }, release() { }, + }; + return { state, repository: new DocumentIngestionRepository({ async connect() { return client; } } as unknown as Pool) }; +} +test("retry repetido preserva a tentativa pendente", async () => { + const { state, repository } = createRepository(); + await repository.retry("project", "document"); + await repository.retry("project", "document"); + assert.equal(state.updates, 1); +}); +test("retry ativo retorna conflito sem alterar a tentativa", async () => { + const { state, repository } = createRepository("processando", new Date(Date.now() + 60000)); + await assert.rejects(repository.retry("project", "document"), /Documento em processamento/); + assert.equal(state.updates, 0); + assert.equal(state.rollback, true); +}); +test("retry recupera lease expirado", async () => { + const { state, repository } = createRepository("processando", new Date(0)); + await repository.retry("project", "document"); + assert.equal(state.status, "pendente"); + assert.equal(state.updates, 1); +}); +test("motivos específicos não expõem códigos desconhecidos", () => { + assert.match(ingestionFailureReason("INVALID_DOCUMENT"), /extrair/); + assert.match(ingestionFailureReason("TIMEOUT"), /tempo limite/); + assert.doesNotMatch(ingestionFailureReason("segredo-token"), /segredo/); +}); diff --git a/backend/src/modules/documents/documents.types.ts b/backend/src/modules/documents/documents.types.ts index d393701..73d901a 100644 --- a/backend/src/modules/documents/documents.types.ts +++ b/backend/src/modules/documents/documents.types.ts @@ -16,6 +16,7 @@ export interface DocumentRecord { nova_tentativa_pendente?: boolean; tentativas_processamento?: number; proxima_tentativa_em?: string | null; + erro_processamento?: string | null; erro_processamento_codigo?: string | null; autor_id: string | null; autor_nome: string | null; diff --git a/backend/src/modules/projects/projects.archive.ts b/backend/src/modules/projects/projects.archive.ts index 434ffae..af94093 100644 --- a/backend/src/modules/projects/projects.archive.ts +++ b/backend/src/modules/projects/projects.archive.ts @@ -108,6 +108,10 @@ export class ProjectArchiveRepository { return existingProject; } + const activeAnalysis = await client.query( + "SELECT 1 FROM analise_repositorio WHERE projeto_id=$1 AND status NOT IN ('concluido','falha','cancelada') LIMIT 1", [id]); + if (activeAnalysis.rowCount) throw new ArchiveConflict("Conclua ou cancele a análise de repositório antes de arquivar o projeto."); + const impact = ( await this.archiveImpact( id, diff --git a/backend/src/modules/repo-analyses/repo-analyses.dispatch.db.test.ts b/backend/src/modules/repo-analyses/repo-analyses.dispatch.db.test.ts new file mode 100644 index 0000000..dda0be2 --- /dev/null +++ b/backend/src/modules/repo-analyses/repo-analyses.dispatch.db.test.ts @@ -0,0 +1,67 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { Pool } from 'pg'; +import { validateTarget } from '../../database/seed-lib'; +import { RepoAnalysesRepository } from './repo-analyses.repository'; +import { ProjectArchiveRepository } from '../projects/projects.archive'; + +test('solicitação idempotente, lease exclusiva e bloqueio com arquivamento', { skip: !process.env.ARCHIVE_TEST_DATABASE_URL }, async () => { + const db = new Pool({ connectionString: validateTarget(process.env.ARCHIVE_TEST_DATABASE_URL, 'test') }); + const user = randomUUID(), project = randomUUID(), other = randomUUID(), concurrent = randomUUID(); + try { + await db.query("INSERT INTO usuario(id,nome,email,senha_hash,role) VALUES($1,'Teste',$2,'hash','po')", [user, user+'@test.local']); + for (const id of [project, other, concurrent]) await db.query("INSERT INTO projeto(id,nome,cliente) VALUES($1::uuid,$1::text,'Teste')",[id]); + const repository = new RepoAnalysesRepository(db); + const input = { projetoId: project, usuarioId: user, repositorioUrl: 'https://github.com/acme/repo', runId: randomUUID(), requestKey: randomUUID(), profile: 'quick' }; + const [a,b] = await Promise.all([repository.create(input),repository.create(input)]); + assert.equal(a.id,b.id); assert.equal(a.dispatch_pending,true); + await assert.rejects(repository.create({...input, requestKey: randomUUID()}), /já possui/); + await assert.rejects(new ProjectArchiveRepository(db).archive(project), /cancele/); + const leases = await Promise.all([repository.claimDispatch(), repository.claimDispatch()]); + const own = leases.filter(row=>row?.id === a.id); + assert.equal(own.length,1); + await repository.finishDispatch({...own[0]!, dispatch_lease:randomUUID()}); + assert.equal((await repository.findById(a.id,project))!.dispatch_pending,true); + await repository.finishDispatch(own[0]!); + assert.equal((await repository.findById(a.id,project))!.dispatch_pending,false); + await db.query("UPDATE analise_repositorio SET status='pausada' WHERE id=$1",[a.id]); + const resumed = await repository.queueResume(a.id, project); + assert.equal(resumed?.resume_requested,true); + assert.equal(resumed?.dispatch_pending,true); + const stale = { status: 'falha', etapa: 'error', etapaLabel: 'Resposta antiga', progresso: 0 }; + await repository.updateStatus(a.run_id, stale, a.revision ?? 0); + assert.equal((await repository.findById(a.id,project))?.status,'iniciado'); + // Even after dispatch finishes, an earlier HTTP response cannot overwrite the resumed run. + await db.query('UPDATE analise_repositorio SET dispatch_pending=FALSE WHERE id=$1',[a.id]); + await repository.updateStatus(a.run_id, stale, a.revision ?? 0); + assert.equal((await repository.findById(a.id,project))?.status,'iniciado'); + const fresh = (await repository.findById(a.id,project))!; + await repository.updateStatus(a.run_id, { ...stale, status: 'em_execucao' }, fresh.revision!); + assert.equal((await repository.findById(a.id,project))?.status,'em_execucao'); + await db.query('UPDATE analise_repositorio SET dispatch_pending=TRUE WHERE id=$1',[a.id]); + await assert.rejects(new ProjectArchiveRepository(db).archive(project), /cancele/); + const cancelling = await repository.cancelPending(a.id,project); + assert.equal(cancelling?.status,'cancelando'); + assert.equal(cancelling?.dispatch_pending,true); + await new ProjectArchiveRepository(db).archive(other); + await assert.rejects(repository.create({...input,projetoId:other,requestKey:randomUUID()}), /arquivado/); + const race = await Promise.allSettled([ + repository.create({...input,projetoId:concurrent,requestKey:randomUUID(),runId:randomUUID()}), + new ProjectArchiveRepository(db).archive(concurrent), + ]); + assert.equal(race.filter(result=>result.status==='fulfilled').length,1); + const rows = await repository.findByProjectId(concurrent); + const status = (await db.query('SELECT status FROM projeto WHERE id=$1',[concurrent])).rows[0].status; + assert.equal(rows.length, status==='arquivado' ? 0 : 1); + if (rows.length) { + const cancelled = await repository.cancelPending(rows[0].id,concurrent); + assert.equal(cancelled?.status,'cancelada'); + assert.equal(cancelled?.dispatch_pending,false); + await new ProjectArchiveRepository(db).archive(concurrent); + } + } finally { + await db.query('DELETE FROM projeto WHERE id=ANY($1::uuid[])',[[project,other,concurrent]]); + await db.query('DELETE FROM usuario WHERE id=$1',[user]); await db.end(); + } +}); diff --git a/backend/src/modules/repo-analyses/repo-analyses.recovery.test.ts b/backend/src/modules/repo-analyses/repo-analyses.recovery.test.ts new file mode 100644 index 0000000..71b51c3 --- /dev/null +++ b/backend/src/modules/repo-analyses/repo-analyses.recovery.test.ts @@ -0,0 +1,61 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import axios from 'axios'; +import { RepoAnalysesService } from './repo-analyses.service'; +import { RepoAnalysesRepository } from './repo-analyses.repository'; +import type { RepoAnalysisRecord } from './repo-analyses.types'; + +test('recupera relatório em consulta posterior à conclusão após falha transitória', async t => { + const record = { id: 'a', projeto_id: 'p', run_id: 'r', status: 'em_execucao' } as RepoAnalysisRecord; + let reports = 0; + t.mock.method(axios, 'get', async (url: string) => { + if (url.endsWith('/report')) { + if (++reports === 1) throw new Error('network'); + return { data: '# Relatório recuperado' }; + } + return { data: { status: 'completed', stage: 'done' } }; + }); + const repository = { + findById: async () => record, + updateStatus: async (_: string, update: { status: string; relatorioMarkdown?: string }) => { + Object.assign(record, { status: update.status, relatorio_markdown: update.relatorioMarkdown }); + }, + } as unknown as RepoAnalysesRepository; + const service = new RepoAnalysesService(repository); + await service.getById('p', 'a'); + assert.equal(record.status, 'concluido'); + assert.equal(record.relatorio_markdown, undefined); + await service.getById('p', 'a'); + assert.equal(record.relatorio_markdown, '# Relatório recuperado'); + await service.getById('p', 'a'); + assert.equal(reports, 2); +}); + +test('falha ao persistir solicitação não inicia trabalho no Python', async t => { + const post = t.mock.method(axios, 'post', async () => { throw new Error('não deveria enviar'); }); + const repository = { create: async () => { throw new Error('DB unavailable'); } } as unknown as RepoAnalysesRepository; + await assert.rejects(new RepoAnalysesService(repository).startAnalysis('p', 'u', 'https://github.com/acme/repo'), /DB unavailable/); + assert.equal(post.mock.callCount(), 0); +}); + +test('retry de despacho após resposta perdida mantém o identificador da execução', async t => { + const record = { id: 'a', run_id: 'stable-id', repositorio_url: 'https://github.com/acme/repo', perfil: 'quick' }; + const sent: unknown[] = []; + let attempts = 0; + t.mock.method(axios, 'post', async (_url: string, body: unknown) => { + sent.push(body); + if (++attempts === 1) throw new Error('response lost'); + return { data: { run_id: record.run_id } }; + }); + const results: (string | undefined)[] = []; + const repository = { + claimDispatch: async () => record, + findById: async () => record, + finishDispatch: async (_record: unknown, error?: string) => { results.push(error); }, + findSyncable: async () => [], + } as unknown as RepoAnalysesRepository; + const service = new RepoAnalysesService(repository); + await service.processQueue(); await service.processQueue(); + assert.deepEqual(sent[0], sent[1]); + assert.ok(results[0]); assert.equal(results[1], undefined); +}); diff --git a/backend/src/modules/repo-analyses/repo-analyses.repository.ts b/backend/src/modules/repo-analyses/repo-analyses.repository.ts index 26cbd6c..76a0926 100644 --- a/backend/src/modules/repo-analyses/repo-analyses.repository.ts +++ b/backend/src/modules/repo-analyses/repo-analyses.repository.ts @@ -1,21 +1,92 @@ -import { pool } from '../../database/db'; // Ajuste conforme a conexão do seu projeto +import { randomUUID } from 'node:crypto'; +import { Pool } from 'pg'; +import { pool } from '../../database/db'; +import { withTransaction } from '../../database/transaction'; +import { assertWritable, lockHierarchy } from '../projects/hierarchy-archive'; +import { AppError, ConflictError } from '../../shared/errors'; import { RepoAnalysisRecord } from './repo-analyses.types'; export class RepoAnalysesRepository { - async create(data: { - projetoId: string; - usuarioId: string; - repositorioUrl: string; - runId: string; - }): Promise { - const query = ` - INSERT INTO analise_repositorio (projeto_id, usuario_id, repositorio_url, run_id, status, etapa, etapa_label, progresso) - VALUES ($1, $2, $3, $4, 'iniciado', 'ollama', 'Iniciando conexão...', 0) - RETURNING *; - `; - const values = [data.projetoId, data.usuarioId, data.repositorioUrl, data.runId]; - const result = await pool.query(query, values); - return result.rows[0]; + constructor(private readonly db: Pool = pool) {} + + async create(data: { projetoId: string; usuarioId: string; repositorioUrl: string; + runId: string; requestKey: string; profile: string }): Promise { + return withTransaction(this.db, async client => { + // Serialize a user's quota check, then use the same project lock as archive. + await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [data.usuarioId]); + await lockHierarchy(client, 'projeto', data.projetoId); + const existing = (await client.query( + 'SELECT * FROM analise_repositorio WHERE projeto_id=$1 AND usuario_id=$2 AND request_key=$3', + [data.projetoId, data.usuarioId, data.requestKey])).rows[0]; + if (existing) { + if (existing.repositorio_url !== data.repositorioUrl || existing.perfil !== data.profile) + throw new ConflictError('Chave de solicitação já utilizada com outro conteúdo.'); + return existing; + } + await assertWritable(client, 'projeto', data.projetoId); + const active = await client.query( + "SELECT projeto_id FROM analise_repositorio WHERE (usuario_id=$1 OR projeto_id=$2) AND status NOT IN ('concluido','falha','cancelada')", + [data.usuarioId, data.projetoId]); + if (active.rows.some(row => row.projeto_id === data.projetoId)) + throw new ConflictError('Este projeto já possui uma análise ativa. Conclua ou cancele antes de iniciar outra.'); + if (active.rowCount! >= 3) throw new AppError('Limite de três análises ativas por usuário.', 429, 'ANALYSIS_LIMIT'); + return (await client.query( + `INSERT INTO analise_repositorio + (projeto_id, usuario_id, repositorio_url, run_id, request_key, perfil, dispatch_pending, status, etapa, etapa_label, progresso) + VALUES ($1,$2,$3,$4,$5,$6,TRUE,'iniciado','queued','Na fila',0) RETURNING *`, + [data.projetoId, data.usuarioId, data.repositorioUrl, data.runId, data.requestKey, data.profile])).rows[0]; + }); + } + + async claimDispatch(): Promise { + const result = await this.db.query(`UPDATE analise_repositorio SET dispatch_lease=$1, + dispatch_after=NOW()+INTERVAL '60 seconds', dispatch_attempts=dispatch_attempts+1 + WHERE id=(SELECT id FROM analise_repositorio WHERE dispatch_pending AND dispatch_after<=NOW() + ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING *`, [randomUUID()]); + return result.rows[0] ?? null; + } + + async cancelPending(id: string, projetoId: string): Promise { + return (await this.db.query(`UPDATE analise_repositorio SET cancel_requested=TRUE, + status=CASE WHEN dispatch_attempts=0 THEN 'cancelada' ELSE 'cancelando' END, + dispatch_pending=dispatch_attempts>0, updated_at=NOW() + WHERE id=$1 AND projeto_id=$2 AND dispatch_pending RETURNING *`, [id, projetoId])).rows[0] ?? null; + } + + async queueResume(id: string, projetoId: string): Promise { + const record = await this.findById(id, projetoId); + if (!record) return null; + return withTransaction(this.db, async client => { + await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [record.usuario_id]); + await lockHierarchy(client, 'projeto', projetoId); + await assertWritable(client, 'projeto', projetoId); + const current = (await client.query('SELECT * FROM analise_repositorio WHERE id=$1 FOR UPDATE', [id])).rows[0]; + if (current.resume_requested) return current; + if (current.status !== 'pausada' && !(current.status === 'falha' && current.metadados?.can_resume)) + throw new ConflictError('A análise não possui progresso retomável.'); + const active = await client.query("SELECT projeto_id FROM analise_repositorio WHERE id<>$3 AND (usuario_id=$1 OR projeto_id=$2) AND status NOT IN ('concluido','falha','cancelada')", [record.usuario_id, projetoId, id]); + if (active.rows.some(row => row.projeto_id === projetoId) || active.rowCount! >= 3) + throw new ConflictError('Limite de análises ativas atingido.'); + return (await client.query(`UPDATE analise_repositorio SET resume_requested=TRUE, cancel_requested=FALSE, + dispatch_pending=TRUE, dispatch_after=NOW(), status='iniciado', concluido_em=NULL, erro=NULL WHERE id=$1 RETURNING *`, [id])).rows[0]; + }); + } + + async finishDispatch(record: RepoAnalysisRecord, error?: string, permanent = false, cancelled = false): Promise { + await this.db.query(`UPDATE analise_repositorio SET dispatch_pending=$3 OR (cancel_requested AND NOT $6 AND NOT $4), + dispatch_lease=NULL, dispatch_after=NOW()+make_interval(secs => LEAST(300, dispatch_attempts*10)), + resume_requested=CASE WHEN $3 THEN resume_requested ELSE FALSE END, + status=CASE WHEN $4 THEN 'falha' ELSE status END, + etapa_label=CASE WHEN $4 THEN 'Falha ao iniciar' ELSE etapa_label END, + erro=$5, updated_at=NOW() + WHERE id=$1 AND dispatch_lease=$2`, + [record.id, record.dispatch_lease, Boolean(error) && !permanent, permanent, error ?? null, cancelled]); + } + + async findSyncable(): Promise { + return (await this.db.query(`SELECT * FROM analise_repositorio WHERE NOT dispatch_pending AND + (status IN ('iniciado','em_execucao','pausando','cancelando') OR + (status='concluido' AND relatorio_markdown IS NULL)) ORDER BY updated_at LIMIT 20`)).rows; } async findById(id: string, projetoId: string): Promise { @@ -25,7 +96,7 @@ export class RepoAnalysesRepository { LEFT JOIN usuario u ON ar.usuario_id = u.id WHERE ar.id = $1 AND ar.projeto_id = $2; `; - const result = await pool.query(query, [id, projetoId]); + const result = await this.db.query(query, [id, projetoId]); return result.rows[0] || null; } @@ -37,7 +108,7 @@ export class RepoAnalysesRepository { WHERE ar.projeto_id = $1 ORDER BY ar.created_at DESC; `; - const result = await pool.query(query, [projetoId]); + const result = await this.db.query(query, [projetoId]); return result.rows; } @@ -52,23 +123,24 @@ export class RepoAnalysesRepository { erro?: string; relatorioMarkdown?: string; metadados?: any; - } + }, + expectedRevision: number, ): Promise { const query = ` UPDATE analise_repositorio - SET status = $1, - etapa = $2, - etapa_label = $3, - progresso = $4, - mensagem = $5, - erro = $6, - relatorio_markdown = COALESCE($7, relatorio_markdown), - metadados = COALESCE($8, metadados), + SET status = $1::varchar, + etapa = $2::varchar, + etapa_label = $3::varchar, + progresso = $4::integer, + mensagem = $5::text, + erro = $6::text, + relatorio_markdown = COALESCE($7::text, relatorio_markdown), + metadados = COALESCE($8::jsonb, metadados), updated_at = NOW(), - concluido_em = CASE WHEN $1 IN ('concluido', 'falha') THEN NOW() ELSE concluido_em END - WHERE run_id = $9; + concluido_em = CASE WHEN $1::varchar IN ('concluido', 'falha', 'cancelada') THEN COALESCE(concluido_em, NOW()) ELSE concluido_em END + WHERE run_id = $9::varchar AND revision = $10 AND NOT dispatch_pending; `; - await pool.query(query, [ + await this.db.query(query, [ statusData.status, statusData.etapa, statusData.etapaLabel, @@ -78,6 +150,7 @@ export class RepoAnalysesRepository { statusData.relatorioMarkdown || null, statusData.metadados ? JSON.stringify(statusData.metadados) : null, runId, + expectedRevision, ]); } } diff --git a/backend/src/modules/repo-analyses/repo-analyses.routes.test.ts b/backend/src/modules/repo-analyses/repo-analyses.routes.test.ts index 969ee3d..1537d3e 100644 --- a/backend/src/modules/repo-analyses/repo-analyses.routes.test.ts +++ b/backend/src/modules/repo-analyses/repo-analyses.routes.test.ts @@ -17,21 +17,27 @@ const ANALYSIS = 'e0000000-0000-4000-8000-000000000001'; const record = { id: ANALYSIS, projeto_id: ACTIVE, status: 'iniciado', run_id: 'r1' } as RepoAnalysisRecord; class FakeService extends RepoAnalysesService { - public started: Array<{ projectId: string; userId: string; url: string }> = []; + public started: Array<{ projectId: string; userId: string; url: string; profile: string }> = []; public lookups: Array<{ projectId: string; id: string }> = []; + public controls: Array<{ projectId: string; id: string; action: string }> = []; public engineDown = false; constructor() { super({} as never, 'http://localhost:0'); } - async startAnalysis(projectId: string, userId: string, url: string): Promise { + async startAnalysis(projectId: string, userId: string, url: string, profile = 'quick'): Promise { if (!/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+$/.test(url)) throw new ValidationError('URL do repositório GitHub inválida.'); if (this.engineDown) throw new AppError('Falha ao iniciar análise no motor de IA: serviço indisponível', 503, 'ANALYZER_UNAVAILABLE'); - this.started.push({ projectId, userId, url }); + this.started.push({ projectId, userId, url, profile }); return { ...record, projeto_id: projectId }; } + async controlAnalysis(projectId: string, id: string, action: 'pause' | 'resume' | 'cancel'): Promise { + this.controls.push({ projectId, id, action }); + return projectId === ACTIVE && id === ANALYSIS ? record : null; + } + async listByProject(projectId: string): Promise { return projectId === ACTIVE ? [record] : []; } @@ -94,7 +100,14 @@ test('id de projeto malformado retorna 400 e projeto inexistente retorna 404 ant test('inicia análise no projeto ativo com o usuário autenticado', async () => { const response = await call(`/${ACTIVE}/repo-analyses`, { method: 'POST', json: { repositorio_url: 'https://github.com/acme/api' } }); assert.equal(response.status, 201); - assert.deepEqual(service.started.at(-1), { projectId: ACTIVE, userId: 'b0000000-0000-4000-8000-000000000001', url: 'https://github.com/acme/api' }); + assert.deepEqual(service.started.at(-1), { projectId: ACTIVE, userId: 'b0000000-0000-4000-8000-000000000001', url: 'https://github.com/acme/api', profile: 'quick' }); +}); + +test('aceita perfil configurável e rejeita perfil desconhecido', async () => { + const response = await call(`/${ACTIVE}/repo-analyses`, { method: 'POST', json: { repositorio_url: 'https://github.com/acme/api', perfil: 'quick' } }); + assert.equal(response.status, 201); + assert.equal(service.started.at(-1)?.profile, 'quick'); + assert.equal((await call(`/${ACTIVE}/repo-analyses`, { method: 'POST', json: { repositorio_url: 'https://github.com/acme/api', perfil: 'turbo' } })).status, 400); }); test('URL inválida retorna 400 e falha do motor retorna 503 com mensagem legível', async () => { @@ -122,3 +135,16 @@ test('consulta por id respeita o projeto da rota e valida o formato', async () = assert.equal((await call(`/${ACTIVE}/repo-analyses/abc`)).status, 400); assert.deepEqual(await (await call(`/${OTHER}/repo-analyses`)).json(), []); }); + +test('pausa, retoma e cancela uma análise autenticada e vinculada ao projeto', async () => { + for (const action of ['pause', 'resume', 'cancel'] as const) { + assert.equal((await call(`/${ACTIVE}/repo-analyses/${ANALYSIS}/${action}`, { method: 'POST', json: {} })).status, 200); + } + assert.deepEqual(service.controls.slice(-3), [ + { projectId: ACTIVE, id: ANALYSIS, action: 'pause' }, + { projectId: ACTIVE, id: ANALYSIS, action: 'resume' }, + { projectId: ACTIVE, id: ANALYSIS, action: 'cancel' }, + ]); + assert.equal((await call(`/${OTHER}/repo-analyses/${ANALYSIS}/cancel`, { method: 'POST', json: {} })).status, 404); + assert.equal((await call(`/${ARCHIVED}/repo-analyses/${ANALYSIS}/resume`, { method: 'POST', json: {} })).status, 409); +}); diff --git a/backend/src/modules/repo-analyses/repo-analyses.routes.ts b/backend/src/modules/repo-analyses/repo-analyses.routes.ts index e18b71c..af79263 100644 --- a/backend/src/modules/repo-analyses/repo-analyses.routes.ts +++ b/backend/src/modules/repo-analyses/repo-analyses.routes.ts @@ -58,13 +58,38 @@ export function createRepoAnalysesRouter( } const url = req.body?.repositorio_url; if (typeof url !== 'string') throw new ValidationError('Informe a URL do repositório.'); - const analysis = await service.startAnalysis(String(req.params.projectId), req.auth?.id ?? '', url); + const profile = req.body?.perfil ?? 'quick'; + if (!['quick', 'balanced', 'complete'].includes(profile)) { + throw new ValidationError('Perfil inválido. Use quick, balanced ou complete.'); + } + const requestKey = req.get('Idempotency-Key'); + if (requestKey) validateUuid(requestKey, 'Chave da solicitação'); + const analysis = await service.startAnalysis(String(req.params.projectId), req.auth?.id ?? '', url, profile, requestKey); res.status(201).json(analysis); } catch (error) { next(error); } }); + const control = (action: 'pause' | 'resume' | 'cancel') => async (req: Request, res: Response, next: NextFunction) => { + try { + const id = String(req.params.id); + validateUuid(id, 'ID da análise'); + if (action === 'resume' && res.locals.projectStatus === 'arquivado') { + throw new ArchiveConflict('Projeto arquivado é somente leitura e não retoma análises.'); + } + const analysis = await service.controlAnalysis(String(req.params.projectId), id, action); + if (!analysis) throw new NotFoundError('Análise não encontrada.'); + res.json(analysis); + } catch (error) { + next(error); + } + }; + + router.post('/:id/pause', control('pause')); + router.post('/:id/resume', control('resume')); + router.post('/:id/cancel', control('cancel')); + /** * @swagger * /api/v1/projects/{projectId}/repo-analyses: diff --git a/backend/src/modules/repo-analyses/repo-analyses.service.test.ts b/backend/src/modules/repo-analyses/repo-analyses.service.test.ts index 6e94b48..cf99254 100644 --- a/backend/src/modules/repo-analyses/repo-analyses.service.test.ts +++ b/backend/src/modules/repo-analyses/repo-analyses.service.test.ts @@ -3,17 +3,22 @@ import assert from 'node:assert/strict'; import { mapAnalyzerProgress, mapAnalyzerStatus } from './repo-analyses.service'; import { RepoAnalysesService } from './repo-analyses.service'; import { RepoAnalysesRepository } from './repo-analyses.repository'; +import axios from 'axios'; test('mapeia os estados reais do Analyzer para o contrato persistido pela API', () => { assert.equal(mapAnalyzerStatus('queued'), 'iniciado'); assert.equal(mapAnalyzerStatus('running'), 'em_execucao'); + assert.equal(mapAnalyzerStatus('pausing'), 'pausando'); + assert.equal(mapAnalyzerStatus('paused'), 'pausada'); + assert.equal(mapAnalyzerStatus('cancelling'), 'cancelando'); + assert.equal(mapAnalyzerStatus('cancelled'), 'cancelada'); assert.equal(mapAnalyzerStatus('completed'), 'concluido'); assert.equal(mapAnalyzerStatus('failed'), 'falha'); assert.throws(() => mapAnalyzerStatus('unexpected'), /Status desconhecido/); }); test('consulta análise vinculada ao projeto da rota, não apenas por ID', async () => { - const record = { id: 'analysis-1', projeto_id: 'project-a', status: 'concluido', run_id: 'run-1' }; + const record = { id: 'analysis-1', projeto_id: 'project-a', status: 'concluido', run_id: 'run-1', relatorio_markdown: 'Relatório' }; const repository = { findById: async (id: string, projectId: string) => id === record.id && projectId === record.projeto_id ? record : null, } as RepoAnalysesRepository; @@ -25,8 +30,38 @@ test('consulta análise vinculada ao projeto da rota, não apenas por ID', async test('calcula progresso limitado usando os campos stage_index/stage_count do Analyzer', () => { assert.equal(mapAnalyzerProgress(0, 6, 'iniciado'), 0); - assert.equal(mapAnalyzerProgress(3, 6, 'em_execucao'), 50); + assert.equal(mapAnalyzerProgress(3, 6, 'em_execucao'), 33); + assert.equal(mapAnalyzerProgress(4, 6, 'em_execucao', { calls_progress_percent: 50 }), 58); + assert.equal(mapAnalyzerProgress(4, 6, 'em_execucao', { calls_progress_percent: 0 }), 50); assert.equal(mapAnalyzerProgress(6, 6, 'concluido'), 100); assert.equal(mapAnalyzerProgress(99, 6, 'em_execucao'), 99); assert.equal(mapAnalyzerProgress(2, 0, 'em_execucao'), 0); }); + +test('marca como falha uma execução ativa ausente no motor, em vez de deixá-la ativa para sempre', async () => { + const record = { id: 'analysis-1', projeto_id: 'project-a', status: 'em_execucao', run_id: 'run-gone' }; + const updates: Array<{ status: string; etapa: string; mensagem?: string }> = []; + const repository = { + findByProjectId: async () => [record], + updateStatus: async (_runId: string, update: { status: string; etapa: string; mensagem?: string }) => { + updates.push(update); + Object.assign(record, update); + }, + } as unknown as RepoAnalysesRepository; + const originalGet = axios.get; + axios.get = Object.assign(async () => { + throw Object.assign(new Error('not found'), { isAxiosError: true, response: { status: 404 } }); + }, { ...originalGet }); + + try { + const service = new RepoAnalysesService(repository, 'http://localhost:8000'); + const [result] = await service.listByProject('project-a'); + + assert.equal(result.status, 'falha'); + assert.equal(result.etapa, 'error'); + assert.match(result.mensagem ?? '', /Inicie uma nova análise/); + assert.equal(updates.length, 1); + } finally { + axios.get = originalGet; + } +}); diff --git a/backend/src/modules/repo-analyses/repo-analyses.service.ts b/backend/src/modules/repo-analyses/repo-analyses.service.ts index c8f7f02..ae0943f 100644 --- a/backend/src/modules/repo-analyses/repo-analyses.service.ts +++ b/backend/src/modules/repo-analyses/repo-analyses.service.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { serviceHeaders } from "../../shared/service-auth.js"; import axios from 'axios'; import { AppError, ValidationError } from '../../shared/errors'; @@ -8,20 +9,35 @@ import { RepoAnalysisRecord, RepoAnalysisStatus, RepoAnalysisStep } from './repo const ANALYZER_STATUS: Record = { queued: 'iniciado', running: 'em_execucao', + pausing: 'pausando', + paused: 'pausada', + cancelling: 'cancelando', + cancelled: 'cancelada', completed: 'concluido', failed: 'falha', }; +const ACTIVE_STATUSES: RepoAnalysisStatus[] = ['iniciado', 'em_execucao', 'pausando', 'cancelando']; + export function mapAnalyzerStatus(status: string): RepoAnalysisStatus { const mapped = ANALYZER_STATUS[status]; if (!mapped) throw new Error(`Status desconhecido recebido do serviço de análise: ${status}`); return mapped; } -export function mapAnalyzerProgress(stageIndex: number, stageCount: number, status: RepoAnalysisStatus): number { +export function mapAnalyzerProgress( + stageIndex: number, + stageCount: number, + status: RepoAnalysisStatus, + stats?: Record, +): number { if (status === 'concluido') return 100; if (!Number.isFinite(stageIndex) || !Number.isFinite(stageCount) || stageCount <= 0) return 0; - return Math.max(0, Math.min(99, Math.round((stageIndex / stageCount) * 100))); + const completedStages = Math.max(0, Math.min(stageCount, stageIndex - 1)); + const stageProgress = stageIndex === 4 && typeof stats?.calls_progress_percent === 'number' + ? Math.max(0, Math.min(100, stats.calls_progress_percent)) / 100 + : 0; + return Math.max(0, Math.min(99, Math.round(((completedStages + stageProgress) / stageCount) * 100))); } export class RepoAnalysesService { @@ -35,29 +51,43 @@ export class RepoAnalysesService { return githubRegex.test(url.trim()); } - async startAnalysis(projetoId: string, usuarioId: string, repositorioUrl: string): Promise { + async startAnalysis(projetoId: string, usuarioId: string, repositorioUrl: string, profile: 'quick' | 'balanced' | 'complete' = 'quick', requestKey: string = randomUUID()): Promise { if (!this.validateGithubUrl(repositorioUrl)) { throw new ValidationError('URL do repositório GitHub inválida. Utilize o formato https://github.com/usuario/repositorio'); } - // Dispara a execução no ai-service / RepoAnalyzer - try { - const response = await axios.post(`${this.analyzerBaseUrl}/api/analyze`, { url: repositorioUrl }, { headers: serviceHeaders(), timeout: 15_000 }); - - const { run_id } = response.data; - - const record = await this.repository.create({ - projetoId, - usuarioId, - repositorioUrl, - runId: run_id, - }); + return this.repository.create({ projetoId, usuarioId, repositorioUrl: repositorioUrl.trim(), + runId: randomUUID(), requestKey, profile }); + } - return record; - } catch (error: unknown) { - const detail = axios.isAxiosError(error) && typeof error.response?.data?.detail === 'string' ? error.response.data.detail : 'serviço indisponível'; - throw new AppError(`Falha ao iniciar análise no motor de IA: ${detail}`, 503, 'ANALYZER_UNAVAILABLE'); + async processQueue(): Promise { + const record = await this.repository.claimDispatch(); + if (record) { + try { + const response = await axios.post(`${this.analyzerBaseUrl}/api/analyze`, + { url: record.repositorio_url, profile: record.perfil, run_id: record.run_id }, + { headers: serviceHeaders(), timeout: 15_000 }); + if (response.data.run_id !== record.run_id) throw new Error('Identidade da execução divergente'); + const latest = await this.repository.findById(record.id, record.projeto_id); + if (latest?.cancel_requested) { + // Repeated cancel is safe: Python returns the terminal state when already cancelled. + await axios.post(`${this.analyzerBaseUrl}/api/runs/${record.run_id}/cancel`, {}, { headers: serviceHeaders(), timeout: 15_000 }); + } else if (latest?.resume_requested) { + await axios.post(`${this.analyzerBaseUrl}/api/runs/${record.run_id}/resume`, {}, { headers: serviceHeaders(), timeout: 15_000 }); + } + await this.repository.finishDispatch(record, undefined, false, latest?.cancel_requested); + } catch (error) { + const code = axios.isAxiosError(error) ? error.response?.status : undefined; + await this.repository.finishDispatch(record, 'Não foi possível despachar a análise; aguardando nova tentativa.', + code !== undefined && [400, 409, 422].includes(code)); + } } + for (const analysis of await this.repository.findSyncable()) await this.syncAnalysisStatus(analysis); + } + + private needsSync(analysis: RepoAnalysisRecord): boolean { + return !analysis.dispatch_pending && (ACTIVE_STATUSES.includes(analysis.status) || + (analysis.status === 'concluido' && !analysis.relatorio_markdown)); } async listByProject(projetoId: string): Promise { @@ -65,22 +95,45 @@ export class RepoAnalysesService { // Opcional: Atualizar status das análises em andamento consultando o ai-service await Promise.all(analyses - .filter((analysis) => analysis.status === 'iniciado' || analysis.status === 'em_execucao') - .map((analysis) => this.syncAnalysisStatus(analysis.run_id))); + .filter((analysis) => this.needsSync(analysis)) + .map((analysis) => this.syncAnalysisStatus(analysis))); return await this.repository.findByProjectId(projetoId); } async getById(projetoId: string, id: string): Promise { const analysis = await this.repository.findById(id, projetoId); - if (analysis && (analysis.status === 'iniciado' || analysis.status === 'em_execucao')) { - await this.syncAnalysisStatus(analysis.run_id); + if (analysis && this.needsSync(analysis)) { + await this.syncAnalysisStatus(analysis); return await this.repository.findById(id, projetoId); } return analysis; } - private async syncAnalysisStatus(runId: string): Promise { + async controlAnalysis(projetoId: string, id: string, action: 'pause' | 'resume' | 'cancel'): Promise { + const analysis = await this.repository.findById(id, projetoId); + if (!analysis) return null; + if (action === 'resume') return this.repository.queueResume(id, projetoId); + if (analysis.dispatch_pending && action === 'cancel') { + const cancelled = await this.repository.cancelPending(id, projetoId); + if (cancelled) return cancelled; + } else if (analysis.dispatch_pending) throw new AppError('A análise aguarda despacho. Tente novamente em instantes.', 409, 'ANALYSIS_QUEUED'); + if (!analysis.run_id) throw new AppError('Esta análise não possui execução retomável.', 409, 'ANALYSIS_NOT_RESUMABLE'); + try { + await axios.post(`${this.analyzerBaseUrl}/api/runs/${encodeURIComponent(analysis.run_id)}/${action}`, {}, { headers: serviceHeaders(), timeout: 15_000 }); + await this.syncAnalysisStatus(analysis); + return await this.repository.findById(id, projetoId); + } catch (error: unknown) { + const status = axios.isAxiosError(error) ? error.response?.status : undefined; + const detail = axios.isAxiosError(error) && typeof error.response?.data?.detail === 'string' + ? error.response.data.detail : 'serviço indisponível'; + if (status === 409) throw new AppError(detail, 409, 'ANALYSIS_CONTROL_CONFLICT'); + throw new AppError(`Falha ao ${action === 'pause' ? 'pausar' : 'cancelar'} análise: ${detail}`, 503, 'ANALYZER_UNAVAILABLE'); + } + } + + private async syncAnalysisStatus(analysis: RepoAnalysisRecord): Promise { + const runId = analysis.run_id; try { const response = await axios.get(`${this.analyzerBaseUrl}/api/runs/${runId}`, { headers: serviceHeaders(), timeout: 15_000 }); const data = response.data; @@ -92,7 +145,8 @@ export class RepoAnalysesService { if (status === 'concluido') { try { const reportRes = await axios.get(`${this.analyzerBaseUrl}/api/runs/${runId}/report`, { headers: serviceHeaders(), timeout: 15_000 }); - relatorioMarkdown = reportRes.data.report || reportRes.data; + const report = typeof reportRes.data === 'string' ? reportRes.data : reportRes.data.report; + if (typeof report === 'string' && report.trim()) relatorioMarkdown = report; } catch (e) { // Ignora se o relatório ainda não estiver pronto } @@ -102,14 +156,41 @@ export class RepoAnalysesService { status, etapa, etapaLabel: data.stage_label || etapa, - progresso: mapAnalyzerProgress(Number(data.stage_index), Number(data.stage_count), status), + progresso: mapAnalyzerProgress(Number(data.stage_index), Number(data.stage_count), status, stats), mensagem: data.message, erro: data.error, relatorioMarkdown, metadados: stats, - }); + }, analysis.revision ?? 0); } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 404) { + await this.repository.updateStatus(runId, { + status: 'falha', + etapa: 'error', + etapaLabel: 'Execução indisponível', + progresso: 0, + mensagem: 'O motor de análise não encontrou esta execução. Inicie uma nova análise para continuar.', + erro: 'Execução não encontrada no serviço de análise.', + }, analysis.revision ?? 0); + return; + } console.warn('[RepoAnalyzer] Falha ao sincronizar o status de uma análise; nova tentativa na próxima consulta.'); } } } + +export function startRepoAnalysesWorker(): () => void { + const service = new RepoAnalysesService(); + let busy = false; + const tick = async () => { + if (busy) return; + busy = true; + try { await service.processQueue(); } + catch { console.warn('[RepoAnalyzer] Falha no worker; a solicitação durável será retomada.'); } + finally { busy = false; } + }; + const timer = setInterval(() => void tick(), 5000); + timer.unref(); + void tick(); + return () => clearInterval(timer); +} diff --git a/backend/src/modules/repo-analyses/repo-analyses.types.ts b/backend/src/modules/repo-analyses/repo-analyses.types.ts index 7f387ab..910a98b 100644 --- a/backend/src/modules/repo-analyses/repo-analyses.types.ts +++ b/backend/src/modules/repo-analyses/repo-analyses.types.ts @@ -1,7 +1,8 @@ -export type RepoAnalysisStatus = 'iniciado' | 'em_execucao' | 'concluido' | 'falha'; +export type RepoAnalysisStatus = 'iniciado' | 'em_execucao' | 'pausando' | 'pausada' | 'cancelando' | 'cancelada' | 'concluido' | 'falha'; export type RepoAnalysisStep = 'ollama' | 'clone' | 'scan' | 'files' | 'synthesis' | 'done' | 'error' | 'queued'; export interface RepoAnalysisRecord { + revision?: number; id: string; projeto_id: string; usuario_id: string; @@ -20,8 +21,15 @@ export interface RepoAnalysisRecord { concluido_em?: string; autor_nome?: string; autor_email?: string; + dispatch_pending?: boolean; + dispatch_attempts?: number; + dispatch_lease?: string; + cancel_requested?: boolean; + resume_requested?: boolean; + perfil?: 'quick' | 'balanced' | 'complete'; } export interface StartAnalysisDTO { repositorio_url: string; + perfil?: 'quick' | 'balanced' | 'complete'; } diff --git a/database/init.sql b/database/init.sql index 3c01dec..c372611 100644 --- a/database/init.sql +++ b/database/init.sql @@ -202,7 +202,7 @@ CREATE TABLE IF NOT EXISTS analise_repositorio ( usuario_id UUID NOT NULL REFERENCES usuario(id) ON DELETE CASCADE, repositorio_url VARCHAR(500) NOT NULL, run_id VARCHAR(100), - status VARCHAR(50) NOT NULL DEFAULT 'iniciado', -- 'iniciado', 'em_execucao', 'concluido', 'falha' + status VARCHAR(50) NOT NULL DEFAULT 'iniciado', -- iniciado, em_execucao, pausando, pausada, cancelando, cancelada, concluido, falha etapa VARCHAR(50) DEFAULT 'queued', etapa_label VARCHAR(100) DEFAULT 'Na fila', progresso INT DEFAULT 0, diff --git a/database/migrations/007_repo_analysis.sql b/database/migrations/007_repo_analysis.sql index dd8ffae..87b5061 100644 --- a/database/migrations/007_repo_analysis.sql +++ b/database/migrations/007_repo_analysis.sql @@ -9,7 +9,7 @@ CREATE TABLE IF NOT EXISTS analise_repositorio ( usuario_id UUID NOT NULL REFERENCES usuario(id) ON DELETE CASCADE, repositorio_url VARCHAR(500) NOT NULL, run_id VARCHAR(100), - status VARCHAR(50) NOT NULL DEFAULT 'iniciado', -- 'iniciado', 'em_execucao', 'concluido', 'falha' + status VARCHAR(50) NOT NULL DEFAULT 'iniciado', -- iniciado, em_execucao, pausando, pausada, cancelando, cancelada, concluido, falha etapa VARCHAR(50) DEFAULT 'queued', etapa_label VARCHAR(100) DEFAULT 'Na fila', progresso INT DEFAULT 0, diff --git a/database/migrations/016_analysis_dispatch.sql b/database/migrations/016_analysis_dispatch.sql new file mode 100644 index 0000000..501f2fe --- /dev/null +++ b/database/migrations/016_analysis_dispatch.sql @@ -0,0 +1,11 @@ +-- Existing runs have already been dispatched; only new durable requests are queued. +ALTER TABLE analise_repositorio ADD COLUMN IF NOT EXISTS dispatch_pending BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE analise_repositorio ADD COLUMN IF NOT EXISTS dispatch_attempts INTEGER NOT NULL DEFAULT 0; +ALTER TABLE analise_repositorio ADD COLUMN IF NOT EXISTS dispatch_after TIMESTAMPTZ NOT NULL DEFAULT NOW(); +ALTER TABLE analise_repositorio ADD COLUMN IF NOT EXISTS dispatch_lease UUID; +ALTER TABLE analise_repositorio ADD COLUMN IF NOT EXISTS perfil VARCHAR(20) NOT NULL DEFAULT 'quick'; +ALTER TABLE analise_repositorio ADD COLUMN IF NOT EXISTS request_key UUID; +ALTER TABLE analise_repositorio ADD COLUMN IF NOT EXISTS cancel_requested BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE analise_repositorio ADD COLUMN IF NOT EXISTS resume_requested BOOLEAN NOT NULL DEFAULT FALSE; +CREATE UNIQUE INDEX IF NOT EXISTS idx_analysis_request_key ON analise_repositorio(projeto_id, usuario_id, request_key); +CREATE INDEX IF NOT EXISTS idx_analysis_dispatch ON analise_repositorio(dispatch_after) WHERE dispatch_pending; diff --git a/database/migrations/017_safe_user_role.sql b/database/migrations/017_safe_user_role.sql new file mode 100644 index 0000000..950a61c --- /dev/null +++ b/database/migrations/017_safe_user_role.sql @@ -0,0 +1,2 @@ +-- Preserve existing accounts; only omitted roles in new accounts default to dev. +ALTER TABLE usuario ALTER COLUMN role SET DEFAULT 'dev'; diff --git a/database/migrations/018_analysis_revision.sql b/database/migrations/018_analysis_revision.sql new file mode 100644 index 0000000..093fbc3 --- /dev/null +++ b/database/migrations/018_analysis_revision.sql @@ -0,0 +1,9 @@ +ALTER TABLE analise_repositorio ADD COLUMN revision INTEGER NOT NULL DEFAULT 0; +CREATE FUNCTION increment_analysis_revision() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + NEW.revision := OLD.revision + 1; + RETURN NEW; +END; +$$; +CREATE TRIGGER analysis_revision BEFORE UPDATE ON analise_repositorio +FOR EACH ROW EXECUTE FUNCTION increment_analysis_revision(); diff --git a/docker-compose.yml b/docker-compose.yml index 25042bf..2ce01a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,7 +86,7 @@ services: - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-sinapse_dev_password} - POSTGRES_DB=${POSTGRES_DB:-sinapse} - AI_SERVICE_URL=http://ai-service:8000 - - REPO_ANALYZER_URL=http://ai-service:8000 + - REPO_ANALYZER_URL=${REPO_ANALYZER_URL:-http://ai-service:8000} - AI_SERVICE_TOKEN=${AI_SERVICE_TOKEN:?Configure AI_SERVICE_TOKEN in .env} - DOCUMENT_MAX_SIZE_MB=${DOCUMENT_MAX_SIZE_MB:-20} - DOCUMENT_STORAGE_DIR=/data/documents @@ -110,15 +110,33 @@ services: build: ./ai-service container_name: sinapse-ai-service restart: unless-stopped + cpus: 2 + mem_limit: 1g + pids_limit: 128 expose: - "8000" + volumes: + - repo_analysis_data:/app/workspace_analyzer environment: - AI_SERVICE_TOKEN=${AI_SERVICE_TOKEN:?Configure AI_SERVICE_TOKEN in .env} - AI_SERVICE_PORT=8000 - AI_SERVICE_HOST=0.0.0.0 - OLLAMA_BASE_URL=http://ollama:11434 - OLLAMA_LLM_MODEL=${OLLAMA_LLM_MODEL:-qwen2.5:1.5b} + - OLLAMA_NUM_PREDICT=${OLLAMA_NUM_PREDICT:-1024} + - OLLAMA_NUM_CTX=${OLLAMA_NUM_CTX:-8192} + - OLLAMA_FILE_NUM_PREDICT=${OLLAMA_FILE_NUM_PREDICT:-512} + - OLLAMA_SYNTHESIS_NUM_PREDICT=${OLLAMA_SYNTHESIS_NUM_PREDICT:-3072} - OLLAMA_EMBEDDING_MODEL=${OLLAMA_EMBEDDING_MODEL:-bge-m3} + - MAX_FILES=${MAX_FILES:-1000} + - ANALYZER_QUICK_FILES=${ANALYZER_QUICK_FILES:-8} + - ANALYZER_BALANCED_FILES=${ANALYZER_BALANCED_FILES:-80} + - ANALYZER_MAX_ACTIVE_RUNS=${ANALYZER_MAX_ACTIVE_RUNS:-2} + - ANALYZER_MAX_QUEUED_RUNS=${ANALYZER_MAX_QUEUED_RUNS:-6} + - ANALYZER_WORKSPACE_QUOTA_MB=${ANALYZER_WORKSPACE_QUOTA_MB:-2048} + - MAX_REPO_SIZE_MB=${MAX_REPO_SIZE_MB:-300} + - ANALYZER_CLONE_MEMORY_MB=${ANALYZER_CLONE_MEMORY_MB:-512} + - ANALYZER_CLONE_TIMEOUT_SECONDS=${ANALYZER_CLONE_TIMEOUT_SECONDS:-300} - POSTGRES_HOST=postgres - POSTGRES_PORT=5432 - POSTGRES_USER=${POSTGRES_USER:-sinapse} @@ -152,6 +170,8 @@ volumes: name: sinapse_ollama_data documents_data: name: sinapse_documents_data + repo_analysis_data: + name: sinapse_repo_analysis_data networks: sinapse-network: diff --git a/docs/Architecture/README.md b/docs/Architecture/README.md index 2444799..b629e20 100644 --- a/docs/Architecture/README.md +++ b/docs/Architecture/README.md @@ -72,6 +72,10 @@ O backend guarda conversas e mensagens por usuário, valida a posse da conversa O backend valida o projeto e a URL do GitHub, pede ao FastAPI para iniciar uma execução e persiste o identificador recebido. Consultas seguintes sincronizam estágio, progresso e relatório. O acesso ao projeto e o estado de arquivamento são revalidados nas rotas. +O escopo é escolhido na tela: **Rápida** (padrão) prioriza até 8 arquivos, **Equilibrada** até 80 e **Completa** analisa todos os arquivos elegíveis, respeitando o limite global configurado. A varredura ainda inventaria o repositório para relatar cobertura; somente os arquivos selecionados seguem para inferência local. README, manifestos de dependências e código de produção recebem prioridade sobre documentação extensa, exemplos e arquivos de lock. O perfil rápido usa contexto local de até 8.192 tokens e, quando um resumo individual ou a síntese final atinge seu limite de saída, preserva o trecho gerado, identifica a limitação no relatório e continua a análise. + +Os resumos de arquivos concluídos e os blocos já processados de arquivos grandes são salvos em `WORKSPACE_DIR/runs//checkpoint.json`. Pausar aguarda a chamada atual ao modelo e salva o checkpoint; retomar continua sem repetir blocos ou arquivos já concluídos. Uma falha depois de algum progresso também permite continuar pelo checkpoint. Esses checkpoints e os clones ficam no volume `repo_analysis_data` do serviço Python e sobrevivem à recriação do container. Cancelar encerra a execução em um ponto seguro e mantém o progresso salvo para consulta, mas não permite retomada. + ## Dados e evolução do schema - `database/init.sql` é o baseline aplicado quando um volume PostgreSQL é inicializado pela primeira vez. diff --git a/docs/S2-01-DEMONSTRACAO.md b/docs/S2-01-DEMONSTRACAO.md new file mode 100644 index 0000000..34ee17e --- /dev/null +++ b/docs/S2-01-DEMONSTRACAO.md @@ -0,0 +1,19 @@ +# Demonstração integrada S2-01 + +Data: 07/10/2026, 23:17:44 (America/Sao_Paulo). + +Backend com código local, PostgreSQL 16/pgvector temporário, n8n e IA/Ollama reais. Migrações aplicadas, dados sintéticos, autenticação e chamadas HTTP reais. Worker acionado por tick explícito para controlar a demonstração; agendamento periódico não medido. + +- Upload HTTP 201 em 58 ms; estado pendente. +- Falha controlada no caminho backend → n8n → IA real: token inválido recusado; motivo seguro: O serviço de processamento recusou a solicitação. +- Dois pedidos de retry HTTP 202 preservaram fila, contadores e timestamps. +- Estado processando observado pela API durante execução real. +- Consulta de projetos HTTP 200 durante processamento. +- Estado processado; 1 trecho(s), embeddings reais de 1024 dimensões, origem preservada e documento encontrado na busca. +- Busca no segundo projeto não retornou dados do primeiro. +- PDF truncado falhou na extração da IA real: INVALID_DOCUMENT; motivo: Não foi possível extrair o conteúdo do documento. +- Falha permanente de extração permite nova tentativa manual HTTP 202. + +Asserções aprovadas. Banco e arquivos temporários removidos ao terminar; dados de desenvolvimento preservados. Sem credenciais no relatório. + +Reproduzir no backend com serviços locais ativos: node --import tsx scripts/demo-s201.mts. diff --git a/docs/S2-01-PIPELINE-DOCUMENTOS.md b/docs/S2-01-PIPELINE-DOCUMENTOS.md new file mode 100644 index 0000000..ae10018 --- /dev/null +++ b/docs/S2-01-PIPELINE-DOCUMENTOS.md @@ -0,0 +1,13 @@ +# S2-01 — Pipeline assíncrono de documentos + +Upload retorna com estado pendente. O worker consulta a fila persistida a cada 15 segundos, aguardando finalização do armazenamento e excluindo projetos arquivados. Estados: pendente, processando, processado e falha. + +Tentativas usam lease de três minutos e timeout HTTP de dois minutos. Falhas transitórias usam backoff exponencial e até cinco tentativas por ciclo. Leases expirados são recuperados após reinício; expiração da quinta tentativa informa ATTEMPTS_EXHAUSTED. Falhas permanentes aguardam nova tentativa manual. + +POST /api/v1/projects/{projectId}/documents/{documentId}/reprocess exige admin ou PO com acesso ao projeto e retorna 202. Pedidos repetidos enquanto pendente preservam a fila e os contadores. Lease ativo retorna 409. Após falha ou lease expirado, limpa diagnósticos e reinicia o ciclo. Documentos processados também podem ser reindexados, preservando o contrato existente. + +GET /api/v1/projects/{projectId}/documents expõe estado, erro_processamento (motivo seguro), erro_processamento_codigo, tentativas_processamento e nova_tentativa_pendente. Respostas de leases substituídos e documentos removidos não restauram dados. O Node valida escopo e vetores e substitui os trechos atomicamente com IDs determinísticos e projeto de origem. + +DOCUMENT_INGEST_WEBHOOK_URL seleciona o workflow n8n versionado; sem essa configuração, o backend chama a IA diretamente. Ative o workflow e encaminhe X-Service-Token. O workflow não salva payloads de execução. Logs registram somente IDs, tentativa, código, retry e status HTTP, sem conteúdo, credenciais ou exceções internas. + +Validação: npm test e npm run typecheck no backend. documents.retry.test.ts verifica idempotência, conflito, lease expirado e motivos seguros. documents.ingestion.db.test.ts verifica falha, retry, leases, vetores e remoção com PostgreSQL migrado e ARCHIVE_TEST_DATABASE_URL apontando para um banco de testes permitido. Sem essa variável, os testes de banco são ignorados. Revisão do PR por outra pessoa permanece uma etapa humana. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 164bb75..275a249 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1569,6 +1569,7 @@ paths: - {name: documentId, in: path, required: true, schema: {type: string, format: uuid}} post: summary: Reenfileira a ingestão de um documento (admin ou PO) + description: Pedidos repetidos enquanto pendente preservam a fila e os contadores. Tentativas ativas retornam 409. A indexação substitui atomicamente os trechos do documento. operationId: reprocessDocument security: [{cookieAuth: []}] responses: @@ -1698,6 +1699,12 @@ paths: summary: Inicia uma análise de repositório público do GitHub operationId: startRepoAnalysis security: [{cookieAuth: []}] + parameters: + - name: Idempotency-Key + in: header + required: false + schema: {type: string, format: uuid} + description: Reutilize a mesma chave ao repetir o envio da mesma solicitação. requestBody: required: true content: @@ -1707,9 +1714,14 @@ paths: required: [repositorio_url] properties: repositorio_url: {type: string, example: "https://github.com/usuario/repositorio"} + perfil: + type: string + enum: [quick, balanced, complete] + default: quick + description: Limite priorizado de arquivos para análise; quick e balanced reduzem chamadas ao modelo local. responses: '201': - description: Análise iniciada + description: Solicitação persistida na fila; despacho assíncrono e idempotente '400': $ref: '#/components/responses/ValidationError' '401': @@ -1739,6 +1751,48 @@ paths: '404': description: Projeto ou análise não encontrados + /api/v1/projects/{projectId}/repo-analyses/{id}/pause: + post: + summary: Pausar após concluir o arquivo atual e salvar checkpoint + operationId: pauseRepoAnalysis + security: [{cookieAuth: []}] + parameters: + - {name: projectId, in: path, required: true, schema: {type: string, format: uuid}} + - {name: id, in: path, required: true, schema: {type: string, format: uuid}} + responses: + '200': {description: Análise pausada ou aguardando o ponto seguro de pausa} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {description: Projeto ou análise não encontrados} + '409': {description: Estado da análise não permite pausa} + + /api/v1/projects/{projectId}/repo-analyses/{id}/resume: + post: + summary: Retomar análise a partir do checkpoint salvo + operationId: resumeRepoAnalysis + security: [{cookieAuth: []}] + parameters: + - {name: projectId, in: path, required: true, schema: {type: string, format: uuid}} + - {name: id, in: path, required: true, schema: {type: string, format: uuid}} + responses: + '200': {description: Retomada registrada na fila} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {description: Projeto ou análise não encontrados} + '409': {description: Estado da análise não permite retomada} + + /api/v1/projects/{projectId}/repo-analyses/{id}/cancel: + post: + summary: Cancelar definitivamente a análise + operationId: cancelRepoAnalysis + security: [{cookieAuth: []}] + parameters: + - {name: projectId, in: path, required: true, schema: {type: string, format: uuid}} + - {name: id, in: path, required: true, schema: {type: string, format: uuid}} + responses: + '200': {description: Análise cancelada ou aguardando o ponto seguro de cancelamento} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {description: Projeto ou análise não encontrados} + '409': {description: Estado da análise não permite cancelamento} + /api/v1/search: get: summary: Busca textual no acervo indexado (chunks), com escopo opcional por projeto @@ -3272,6 +3326,10 @@ components: proxima_tentativa_em: type: string nullable: true + erro_processamento: + type: string + nullable: true + description: Motivo seguro da falha, sem conteúdo do documento ou mensagens internas. erro_processamento_codigo: type: string nullable: true diff --git a/e2e/README.md b/e2e/README.md index 371394c..3e21511 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -45,3 +45,9 @@ Variáveis opcionais: `E2E_API_URL`, `E2E_APP_URL`, `E2E_CHROME_PATH` e `E2E_TIM O workflow [E2E (navegador)](../.github/workflows/e2e.yml) roda automaticamente em pull requests para `main` e em pushes para `main` quando mudanças afetam backend, frontend, banco, E2E, Compose ou o próprio workflow. Também pode ser iniciado manualmente pelo `workflow_dispatch`. Cada execução de CI provisiona PostgreSQL descartável, aplica migrations, inicia frontend/backend e executa os cenários no Chrome. O job publica logs dos serviços quando falha. + +## Preparação de identidades e isolamento + +Defina E2E_DATABASE_URL para PostgreSQL descartável cujo banco se chama sinapse_e2e_test; npm test recusa outro nome. O setup cria somente o administrador de teste nesse banco. POs são cadastrados pela API autenticada, desenvolvedores pela API pública, e as alocações usadas pelos cenários são explícitas. Nenhuma exceção é adicionada às rotas de produção. + +O processo e2e/server.mts sobe a aplicação real sem workers de processamento (NODE_ENV=test); a suíte verifica persistência da solicitação e cancelamento antes do despacho. Workers, ingestão e limites do Analyzer têm testes próprios. Isso mantém os E2E independentes de downloads GitHub e inferência Ollama. Execute no diretório backend: NODE_ENV=test node --import tsx ../e2e/server.mts. O frontend deve encaminhar /api para essa API. No Windows, E2E_CHROME_PATH também aceita o executável do Edge/Chromium. diff --git a/e2e/database.mjs b/e2e/database.mjs new file mode 100644 index 0000000..72ff49e --- /dev/null +++ b/e2e/database.mjs @@ -0,0 +1,19 @@ +import { createRequire } from 'node:module'; +const require = createRequire(new URL('../backend/package.json', import.meta.url)); +const { Pool } = require('pg'); + +export async function testDatabase(work) { + const connectionString = process.env.E2E_DATABASE_URL; + if (!connectionString || !/^sinapse_e2e_test$/.test(new URL(connectionString).pathname.slice(1))) { + throw new Error('E2E_DATABASE_URL deve apontar para um banco dedicado chamado sinapse_e2e_test.'); + } + const db = new Pool({ connectionString, max: 1 }); + try { return await work(db); } finally { await db.end(); } +} + +export async function allocateDeveloper(account, project) { + await testDatabase(async db => { + const developer = await db.query('INSERT INTO desenvolvedor(usuario_id) VALUES($1) RETURNING id', [account.user.id]); + await db.query('INSERT INTO alocacao(desenvolvedor_id,projeto_id) VALUES($1,$2)', [developer.rows[0].id, project.id]); + }); +} diff --git a/e2e/run.mjs b/e2e/run.mjs index 651796b..2011ffb 100644 --- a/e2e/run.mjs +++ b/e2e/run.mjs @@ -1,6 +1,9 @@ import { spawnSync } from "node:child_process"; import { readdirSync } from "node:fs"; import { join } from "node:path"; +import { setup } from './setup.mjs'; + +await setup(); const files = readdirSync("tests").filter((file) => file.endsWith(".e2e.mjs")).sort().map((file) => join("tests", file)); if (files.length === 0) throw new Error("Nenhum teste E2E encontrado."); diff --git a/e2e/server.mts b/e2e/server.mts new file mode 100644 index 0000000..9de3d93 --- /dev/null +++ b/e2e/server.mts @@ -0,0 +1,5 @@ +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const { default: app } = require('../backend/src/app.ts'); +const port = Number(process.env.PORT ?? 3001); +app.listen(port, '127.0.0.1', () => console.log(`E2E API: http://127.0.0.1:${port}`)); diff --git a/e2e/setup.mjs b/e2e/setup.mjs new file mode 100644 index 0000000..2bde97f --- /dev/null +++ b/e2e/setup.mjs @@ -0,0 +1,12 @@ +import { randomBytes, scryptSync } from 'node:crypto'; +import { testDatabase } from './database.mjs'; + +// Test-only bootstrap: no production endpoint or change to registration authorization. +export async function setup() { + const salt = randomBytes(16).toString('hex'); + const hash = ['scrypt', 'v1', salt, scryptSync('E2E-admin-only-123', salt, 64).toString('hex')].join('$'); + await testDatabase(async db => { + await db.query(`INSERT INTO usuario(nome,email,senha_hash,role) VALUES('Admin E2E','admin@e2e.test',$1,'admin') + ON CONFLICT(email) DO UPDATE SET senha_hash=$1, role='admin', tentativas_login=0, bloqueado_ate=NULL`, [hash]); + }); +} diff --git a/e2e/support.mjs b/e2e/support.mjs index 0a84966..57ac077 100644 --- a/e2e/support.mjs +++ b/e2e/support.mjs @@ -23,8 +23,14 @@ export async function api(path, { token, method = "GET", body, headers = {} } = } export async function createUser(role) { + let token; + if (role !== 'dev') { + const admin = await api('/auth/login', { method: 'POST', body: { email: 'admin@e2e.test', password: 'E2E-admin-only-123' } }); + if (admin.status !== 200) throw new Error('Execute o setup do banco E2E isolado antes dos testes.'); + token = admin.json.token; + } const email = `${role}-${randomUUID().slice(0, 8)}@e2e.test`; - const registered = await api("/auth/register", { method: "POST", body: { nome: `E2E ${role}`, email, password: PASSWORD, role } }); + const registered = await api("/auth/register", { token, method: "POST", body: { nome: `E2E ${role}`, email, password: PASSWORD, role } }); if (registered.status !== 201 && registered.status !== 200) throw new Error(`register ${role}: ${registered.status} ${JSON.stringify(registered.json)}`); const login = await api("/auth/login", { method: "POST", body: { email, password: PASSWORD } }); if (login.status !== 200) throw new Error(`login ${role}: ${login.status}`); @@ -86,9 +92,9 @@ export async function assertNoHorizontalOverflow(page, label) { } export async function createHierarchy(account, project, labels = {}) { - const epic = await api("/epics", { method: "POST", token: account.token, body: { projeto_id: project.id, titulo: labels.epic ?? "Épico de autenticação", descricao: "Objetivo de acesso seguro à plataforma" } }); + const epic = await api("/epics", { method: "POST", token: account.token, body: { projeto_id: project.id, titulo: labels.epic ?? "Épico de autenticação", descricao: "Objetivo de acesso seguro à plataforma", objetivo: "Garantir acesso seguro", escopo_macro: "Autenticação e sessão", resultado_esperado: "Acessos autenticados" } }); if (epic.status !== 201) throw new Error(`épico: ${epic.status} ${JSON.stringify(epic.json)}`); - const feature = await api("/features", { method: "POST", token: account.token, body: { epico_id: epic.json.id, titulo: labels.feature ?? "Sessão do usuário", descricao: "Controle de sessão e expiração" } }); + const feature = await api("/features", { method: "POST", token: account.token, body: { epico_id: epic.json.id, titulo: labels.feature ?? "Sessão do usuário", descricao: "Controle de sessão e expiração", objetivo: "Gerenciar sessão", criterios_sucesso: "Sessão válida", comportamento_esperado: "Expirar acesso inválido" } }); if (feature.status !== 201) throw new Error(`feature: ${feature.status} ${JSON.stringify(feature.json)}`); const pbi = await api("/pbis", { method: "POST", diff --git a/e2e/tests/assistants.e2e.mjs b/e2e/tests/assistants.e2e.mjs index 13cf730..dce6df5 100644 --- a/e2e/tests/assistants.e2e.mjs +++ b/e2e/tests/assistants.e2e.mjs @@ -22,8 +22,12 @@ test("RepoAnalyzer: valida a URL, explica a indisponibilidade do motor e respeit await page.getByLabel(/URL do repositório/).fill("https://github.com/acme/api"); await page.getByRole("button", { name: "Iniciar análise" }).click(); - await expect(page.getByText(/Falha ao iniciar análise no motor de IA/)).toBeVisible(); - assert.equal(await page.getByLabel(/URL do repositório/).inputValue(), "https://github.com/acme/api"); + await expect(page.getByRole('button', { name: 'Cancelar análise', exact: true })).toBeVisible(); + const pending = await api(`/projects/${project.id}/repo-analyses`, { token: po.token }); + assert.equal(pending.json.length, 1); + assert.equal(pending.json[0].dispatch_pending, true); + const cancelled = await api(`/projects/${project.id}/repo-analyses/${pending.json[0].id}/cancel`, { method:'POST', token:po.token }); + assert.ok(['cancelada','cancelando'].includes(cancelled.json.status)); } finally { await context.close(); } diff --git a/e2e/tests/auth.e2e.mjs b/e2e/tests/auth.e2e.mjs new file mode 100644 index 0000000..bd1c544 --- /dev/null +++ b/e2e/tests/auth.e2e.mjs @@ -0,0 +1,39 @@ +import test, { after } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { APP, getBrowser, closeBrowser, createUser } from '../support.mjs'; +import { expect } from './expect.mjs'; +after(closeBrowser); + +test('login pela tela restaura sessão e cadastro público cria somente dev', async () => { + const account = await createUser('dev'); + const context = await (await getBrowser()).newContext(); + try { + const page = await context.newPage(); + await page.goto(`${APP}/login`); + await page.getByLabel('E-mail', {exact:true}).fill(account.email); + await page.getByLabel('Senha', {exact:true}).fill(account.password); + const login = page.waitForResponse(r=>r.url().endsWith('/auth/login') && r.request().method()==='POST'); + await page.getByRole('button',{name:'Entrar',exact:true}).click(); + assert.equal((await login).status(),200); + await page.goto(`${APP}/projects`); + await expect(page.getByRole('heading',{name:'Projetos',exact:true})).toBeVisible(); + await page.reload(); + await expect(page.getByRole('heading',{name:'Projetos',exact:true})).toBeVisible(); + } finally { await context.close(); } + const anonymous = await (await getBrowser()).newContext(); + try { + const page = await anonymous.newPage(); + await page.goto(`${APP}/login`); + await page.getByRole('button',{name:'Cadastrar',exact:true}).click(); + await page.getByLabel('Nome Completo', {exact:true}).fill('Pessoa E2E'); + await page.getByLabel('E-mail Corporativo', {exact:true}).fill(`ui-${randomUUID()}@e2e.test`); + await page.getByLabel('Senha (mínimo 6 caracteres)', {exact:true}).fill('Senha-forte-123'); + await page.getByLabel(/Confirmar senha/i).fill('Senha-forte-123'); + const registered = page.waitForResponse(r=>r.url().endsWith('/auth/register') && r.request().method()==='POST'); + await page.getByRole('button',{name:'Cadastrar e Entrar',exact:true}).click(); + const response = await registered; + assert.equal(response.status(),201); + assert.equal((await response.json()).user.role,'dev'); + } finally { await anonymous.close(); } +}); diff --git a/e2e/tests/documents.e2e.mjs b/e2e/tests/documents.e2e.mjs index dd56471..8fb526c 100644 --- a/e2e/tests/documents.e2e.mjs +++ b/e2e/tests/documents.e2e.mjs @@ -1,3 +1,4 @@ +import { allocateDeveloper } from '../database.mjs'; import test, { after } from "node:test"; import assert from "node:assert/strict"; import { expect } from "./expect.mjs"; @@ -58,6 +59,7 @@ test("perfil dev consulta mas não escreve, e a API recusa a escrita", async () const po = await createUser("po"); const dev = await createUser("dev"); const project = await createProject(po, "Docs DEV"); + await allocateDeveloper(dev, project); const uploaded = await uploadRaw(po, project.id, "leitura.pdf", pdfContent()); assert.equal(uploaded.status, 201); diff --git a/e2e/tests/flows.e2e.mjs b/e2e/tests/flows.e2e.mjs index fdbaa30..6d21786 100644 --- a/e2e/tests/flows.e2e.mjs +++ b/e2e/tests/flows.e2e.mjs @@ -1,3 +1,4 @@ +import { allocateDeveloper } from '../database.mjs'; import test, { after } from "node:test"; import assert from "node:assert/strict"; import { expect } from "./expect.mjs"; @@ -129,6 +130,7 @@ test("Decisões S1-18: registrar no PBI, ver herança do projeto e restrições const po = await createUser("po"); const dev = await createUser("dev"); const project = await createProject(po, "Decisões"); + await allocateDeveloper(dev, project); const { epic, feature, pbi } = await createHierarchy(po, project); const inherited = await api(`/projects/${project.id}/decisions`, { method: "POST", token: po.token, body: { titulo: "Stack única em TypeScript", contexto: "Equipe pequena", decisao: "Usar TypeScript em todo o código", justificativa: "Reduz troca de contexto", alternativas: "Python no backend" } }); assert.equal(inherited.status, 201); diff --git a/frontend/src/api/api_documents.ts b/frontend/src/api/api_documents.ts index 5baa349..103ee6d 100644 --- a/frontend/src/api/api_documents.ts +++ b/frontend/src/api/api_documents.ts @@ -161,6 +161,6 @@ export function describeRemovalError(error: unknown): string { return "Não foi possível remover o documento. Ele continua disponível; tente novamente."; } -export async function reprocessDocument(projectId: string, documentId: string): Promise { - await apiRequest(`/projects/${encodeURIComponent(projectId)}/documents/${encodeURIComponent(documentId)}/reprocess`, { method: "POST" }); +export async function reprocessDocument(projectId: string, documentId: string, signal?: AbortSignal): Promise { + await apiRequest(`/projects/${encodeURIComponent(projectId)}/documents/${encodeURIComponent(documentId)}/reprocess`, { method: "POST", signal }); } diff --git a/frontend/src/api/api_repo_analyzer.test.ts b/frontend/src/api/api_repo_analyzer.test.ts index 5acde2d..03ffd6d 100644 --- a/frontend/src/api/api_repo_analyzer.test.ts +++ b/frontend/src/api/api_repo_analyzer.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, it, vi } from 'vitest'; -import { startRepoAnalysis } from "./api_repo_analyzer"; +import { controlRepoAnalysis, startRepoAnalysis } from './api_repo_analyzer'; afterEach(() => vi.unstubAllGlobals()); @@ -13,5 +13,14 @@ it('envia a URL no campo repositorio_url esperado pela rota autenticada do backe const [url, options] = request.mock.calls[0]; expect(url).toBe('/api/v1/projects/project-1/repo-analyses'); expect(options?.method).toBe('POST'); - expect(JSON.parse(String(options?.body))).toEqual({ repositorio_url: 'https://github.com/owner/repo' }); + expect(JSON.parse(String(options?.body))).toEqual({ repositorio_url: 'https://github.com/owner/repo', perfil: 'quick' }); +}); + +it('envia controle de pausa para a rota autenticada da análise', async () => { + const request = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify({ id: 'analysis-1', status: 'pausada' }), { status: 200 })); + vi.stubGlobal('fetch', request); + await controlRepoAnalysis('project-1', 'analysis-1', 'pause'); + const [url, options] = request.mock.calls[0]; + expect(url).toBe('/api/v1/projects/project-1/repo-analyses/analysis-1/pause'); + expect(options?.method).toBe('POST'); }); diff --git a/frontend/src/api/api_repo_analyzer.ts b/frontend/src/api/api_repo_analyzer.ts index f1bc962..3271f13 100644 --- a/frontend/src/api/api_repo_analyzer.ts +++ b/frontend/src/api/api_repo_analyzer.ts @@ -1,6 +1,8 @@ import { apiRequest } from "./api_auth"; -export type RepoAnalysisStatus = "iniciado" | "em_execucao" | "concluido" | "falha"; +export type RepoAnalysisStatus = "iniciado" | "em_execucao" | "pausando" | "pausada" | "cancelando" | "cancelada" | "concluido" | "falha"; +export type RepoAnalysisProfile = "quick" | "balanced" | "complete"; +export type RepoAnalysisAction = "pause" | "resume" | "cancel"; export interface RepoAnalysis { id: string; @@ -22,10 +24,20 @@ export interface RepoAnalysis { autor_email?: string | null; } -export async function startRepoAnalysis(projectId: string, repositoryUrl: string, signal?: AbortSignal): Promise { +export async function startRepoAnalysis(projectId: string, repositoryUrl: string, profile: RepoAnalysisProfile = "quick", signal?: AbortSignal, requestKey?: string): Promise { const response = await apiRequest(`/projects/${encodeURIComponent(projectId)}/repo-analyses`, { method: "POST", - body: JSON.stringify({ repositorio_url: repositoryUrl }), + headers: requestKey ? { "Idempotency-Key": requestKey } : undefined, + body: JSON.stringify({ repositorio_url: repositoryUrl, perfil: profile }), + signal, + }); + return await response.json(); +} + +export async function controlRepoAnalysis(projectId: string, analysisId: string, action: RepoAnalysisAction, signal?: AbortSignal): Promise { + const response = await apiRequest(`/projects/${encodeURIComponent(projectId)}/repo-analyses/${encodeURIComponent(analysisId)}/${action}`, { + method: "POST", + body: JSON.stringify({}), signal, }); return await response.json(); diff --git a/frontend/src/assets/styles/repo-analyzer.css b/frontend/src/assets/styles/repo-analyzer.css index 97345e8..be27ae7 100644 --- a/frontend/src/assets/styles/repo-analyzer.css +++ b/frontend/src/assets/styles/repo-analyzer.css @@ -34,6 +34,7 @@ .analysis-stepper li[data-state="failed"] .analysis-step-marker { border-color: var(--status-danger); background: color-mix(in srgb, var(--status-danger) 16%, transparent); color: var(--status-danger); } .analysis-progress { display: grid; gap: 8px; padding: 14px; background: var(--surface-base); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); } +.analysis-controls { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; } .analysis-progress-line { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; font-weight: 600; } .analysis-languages { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; } .analysis-languages li { padding: 3px 10px; border: 1px solid var(--border-subtle); border-radius: 999px; font-size: 12px; color: var(--text-secondary); } diff --git a/frontend/src/models/repoAnalyzer.ts b/frontend/src/models/repoAnalyzer.ts index ad543ac..6c80a70 100644 --- a/frontend/src/models/repoAnalyzer.ts +++ b/frontend/src/models/repoAnalyzer.ts @@ -21,15 +21,19 @@ export function stageStates(analysis: Pick): S }); } -export const STATUS_VIEW: Record = { +export const STATUS_VIEW: Record = { iniciado: { label: "Na fila", tone: "info" }, em_execucao: { label: "Em execução", tone: "brand" }, + pausando: { label: "Pausando", tone: "warning" }, + pausada: { label: "Pausada", tone: "warning" }, + cancelando: { label: "Cancelando", tone: "warning" }, + cancelada: { label: "Cancelada", tone: "danger" }, concluido: { label: "Concluída", tone: "success" }, falha: { label: "Falhou", tone: "danger" }, }; export function isActive(analysis: Pick): boolean { - return analysis.status === "iniciado" || analysis.status === "em_execucao"; + return ["iniciado", "em_execucao", "pausando", "cancelando"].includes(analysis.status); } export function repositoryLabel(url: string): string { @@ -56,7 +60,8 @@ export function formatDuration(seconds: number): string { export function analysisDuration(analysis: Pick, now: number = Date.now()): number | null { const start = Date.parse(analysis.created_at); if (Number.isNaN(start)) return null; - const finishedAt = analysis.concluido_em ? Date.parse(analysis.concluido_em) : analysis.status === "falha" ? Date.parse(analysis.updated_at) : now; + const terminal = ["falha", "pausada", "cancelada"].includes(analysis.status); + const finishedAt = analysis.concluido_em ? Date.parse(analysis.concluido_em) : terminal ? Date.parse(analysis.updated_at) : now; if (Number.isNaN(finishedAt)) return null; return Math.max(0, (finishedAt - start) / 1000); } diff --git a/frontend/src/views/chat/ChatView.test.tsx b/frontend/src/views/chat/ChatView.test.tsx index b52b46d..d143259 100644 --- a/frontend/src/views/chat/ChatView.test.tsx +++ b/frontend/src/views/chat/ChatView.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { ChatView } from "./ChatView"; const PROJECT = { id: "p-1", nome: "Sinapse", cliente: "C", descricao: "", status: "ativo" }; @@ -241,3 +241,36 @@ it("copia a resposta do assistente", async () => { await waitFor(() => expect(writeText).toHaveBeenCalledWith("Resposta **importante**")); expect(await screen.findByRole("button", { name: "Copiado" })).toBeInTheDocument(); }); + +it("permite enviar em B enquanto A responde e recupera A ao retornar", async () => { + let finish!: (response: Response) => void; + const routes: Routes = { + conversations: [conversation(), conversation({ id: 'c-2', titulo: 'Conversa B' })], + messages: { 'c-1': [message()], 'c-2': [] }, + query: body => body.conversa_id === 'c-1' + ? new Promise(resolve => { finish = resolve; }) + : json({ conversa_id: 'c-2', resposta: 'Resposta B', fontes: [], origem: 'assistente' }), + }; + const request = api(routes); + vi.stubGlobal('fetch', request); + render(); + await screen.findByText('importante'); + fireEvent.change(composer(), { target: { value: 'Pergunta A' } }); + fireEvent.click(screen.getByRole('button', { name: 'Enviar' })); + await waitFor(() => expect(finish).toBeDefined()); + fireEvent.click(screen.getByRole('button', { name: /Conversa B/ })); + expect(composer()).toBeEnabled(); + fireEvent.change(composer(), { target: { value: 'Pergunta B' } }); + fireEvent.click(screen.getByRole('button', { name: 'Enviar' })); + await screen.findByText('Resposta B'); + fireEvent.click(screen.getByRole('button', { name: /Decisões do login/ })); + await screen.findByText('importante'); + expect(composer()).toBeDisabled(); + routes.messages!['c-1'] = [message({ conteudo: 'Resposta A concluída' })]; + await act(async () => { + finish(await json({ conversa_id: 'c-1', resposta: 'Resposta A concluída', fontes: [], origem: 'assistente' })); + }); + expect(await screen.findByText('Resposta A concluída')).toBeInTheDocument(); + expect(screen.queryByText('Resposta B')).not.toBeInTheDocument(); + expect(composer()).toBeEnabled(); +}); diff --git a/frontend/src/views/chat/ChatView.tsx b/frontend/src/views/chat/ChatView.tsx index 08b7448..bb78db6 100644 --- a/frontend/src/views/chat/ChatView.tsx +++ b/frontend/src/views/chat/ChatView.tsx @@ -36,15 +36,20 @@ export function ChatView() { const [messages, setMessages] = useState([]); const [messagesState, setMessagesState] = useState("idle"); const [draft, setDraft] = useState(""); - const [sending, setSending] = useState(false); + const [pendingKeys, setPendingKeys] = useState([]); const [sendError, setSendError] = useState(""); const [copiedId, setCopiedId] = useState(null); const bottom = useRef(null); const composer = useRef(null); const mounted = useRef(true); const messagesRequest = useRef(0); - const sendLock = useRef(false); + const sendLocks = useRef(new Set()); const skipReload = useRef(null); + const conversationRevision = useRef(0); + const selectedConversation = useRef(activeId); + selectedConversation.current = activeId; + const sendKey = activeId ?? `draft-${conversationRevision.current}`; + const sending = pendingKeys.includes(sendKey); useEffect(() => { mounted.current = true; @@ -113,6 +118,8 @@ export function ChatView() { const scopeLocked = Boolean(activeId); const startNewConversation = () => { + conversationRevision.current += 1; + messagesRequest.current += 1; setActiveId(null); setMessages([]); setSendError(""); @@ -121,6 +128,11 @@ export function ChatView() { }; const openConversation = (conversation: ChatConversation) => { + if (conversation.id === activeId) return; + conversationRevision.current += 1; + messagesRequest.current += 1; + setMessages([]); + setDraft(""); setSendError(""); setActiveId(conversation.id); setScopeId(conversation.projeto_id ?? ""); @@ -128,9 +140,10 @@ export function ChatView() { const send = useCallback(async (text: string, retryId?: string) => { const question = text.trim(); - if (!question || sendLock.current || !scopeId) return; - sendLock.current = true; - setSending(true); + if (!question || sendLocks.current.has(sendKey) || !scopeId) return; + sendLocks.current.add(sendKey); + const revision = conversationRevision.current; + setPendingKeys(current => [...current, sendKey]); setSendError(""); const pendingId = retryId ?? `local-${Date.now()}`; setMessages((current) => retryId @@ -140,6 +153,11 @@ export function ChatView() { try { const answer = await askQuestion({ pergunta: question, conversaId: activeId, projetoId: activeId ? null : scopeId }); if (!mounted.current) return; + void loadConversations(false); + if (revision !== conversationRevision.current) { + if (selectedConversation.current === answer.conversa_id) void loadMessages(answer.conversa_id); + return; + } setMessages((current) => [ ...current, { id: `assistant-${Date.now()}`, remetente: "assistant", conteudo: answer.resposta, fontes: answer.fontes, created_at: new Date().toISOString(), origem: answer.origem }, @@ -148,9 +166,12 @@ export function ChatView() { skipReload.current = answer.conversa_id; setActiveId(answer.conversa_id); } - void loadConversations(false); } catch (error) { if (!mounted.current) return; + if (revision !== conversationRevision.current) { + if (activeId && selectedConversation.current === activeId) void loadMessages(activeId); + return; + } setMessages((current) => current.map((item) => (item.id === pendingId ? { ...item, failed: true } : item))); if (error instanceof ApiError && error.details && typeof error.details === "object" && "details" in error.details) { const detail = error.details.details as { conversa_id?: string }; @@ -158,10 +179,10 @@ export function ChatView() { } setSendError(describeChatError(error)); } finally { - sendLock.current = false; - if (mounted.current) setSending(false); + sendLocks.current.delete(sendKey); + if (mounted.current) setPendingKeys(current => current.filter(key => key !== sendKey)); } - }, [activeId, scopeId, loadConversations, loadMessages]); + }, [activeId, scopeId, sendKey, loadConversations, loadMessages]); const copy = async (message: ThreadMessage) => { try { @@ -198,7 +219,7 @@ export function ChatView() { className="ds-input" value={scopeId} disabled={scopeLocked} - onChange={(event) => setScopeId(event.target.value)} + onChange={(event) => { conversationRevision.current += 1; setMessages([]); setSendError(""); setScopeId(event.target.value); }} aria-describedby="chat-scope-help" > diff --git a/frontend/src/views/documents/DocumentsView.test.tsx b/frontend/src/views/documents/DocumentsView.test.tsx index 3a8c7b6..03edec7 100644 --- a/frontend/src/views/documents/DocumentsView.test.tsx +++ b/frontend/src/views/documents/DocumentsView.test.tsx @@ -313,3 +313,82 @@ it("acompanha a recuperação automática de um documento em falha", async () => expect(clear).toHaveBeenCalledWith(123); } finally {timer.mockRestore();clear.mockRestore();} }); + +it("polling atualiza todas as páginas carregadas sem perder itens nem duplicar", async () => { + let poll: (() => void) | undefined; + const nativeInterval = window.setInterval.bind(window); + const interval = vi.spyOn(window, 'setInterval').mockImplementation((fn, delay, ...args) => { + if (delay === 10_000) { poll = fn as () => void; return 1; } + return nativeInterval(fn, delay, ...args); + }); + const request = vi.fn((_url: RequestInfo | URL) => String(_url).includes('cursor=') + ? listing([doc({ id: 'd-2', nome: 'Segunda.txt' })]) + : listing([doc()], 'page-2')); + vi.stubGlobal('fetch', request); + try { + view(); + await screen.findByText('Escopo.pdf'); + fireEvent.click(screen.getByRole('button', { name: 'Carregar mais' })); + await screen.findByText('Segunda.txt'); + await act(async () => { poll!(); }); + await waitFor(() => expect(request).toHaveBeenCalledTimes(4)); + expect(screen.getAllByText('Segunda.txt')).toHaveLength(1); + expect(screen.getByText('Documentos (2)')).toBeInTheDocument(); + } finally { interval.mockRestore(); } +}); + +for (const operation of ['refresh', 'more', 'upload', 'remove', 'reprocess'] as const) { + it(`descarta ${operation} atrasado após trocar o projeto e cancela a requisição`, async () => { + let finish!: (value: Response) => void; + let pendingSignal: AbortSignal | undefined; + let postpone = false; + vi.stubGlobal('fetch', vi.fn((url: RequestInfo | URL, init?: RequestInit) => { + if (String(url).includes('/p-2/')) return listing([doc({ id: 'b', projeto_id: 'p-2', nome: 'Projeto B.txt' })]); + if (postpone) { + pendingSignal = init?.signal as AbortSignal; + return new Promise(resolve => { finish = resolve; }); + } + return listing([doc({ status_processamento: 'falha' })], 'next'); + })); + const rendered = view(); + await screen.findByText('Escopo.pdf'); + if (operation === 'upload') pick(new File(['text'], 'Upload A.txt', { type: 'text/plain' })); + if (operation === 'remove') fireEvent.click(screen.getByRole('button', { name: 'Remover Escopo.pdf' })); + postpone = true; + fireEvent.click(screen.getByRole('button', { name: { + refresh: 'Atualizar', more: 'Carregar mais', upload: 'Enviar documento', + remove: 'Confirmar remoção', reprocess: 'Reprocessar', + }[operation] })); + await waitFor(() => expect(finish).toBeDefined()); + rendered.rerender(); + await screen.findByText('Projeto B.txt'); + expect(pendingSignal?.aborted).toBe(true); + await act(async () => { + finish(new Response(JSON.stringify(operation === 'upload' ? doc({nome:'Upload A.txt'}) : { items:[doc()], next_cursor:null, limites }), {status:200})); + }); + expect(screen.queryByText('Escopo.pdf')).not.toBeInTheDocument(); + expect(screen.queryByText('Upload A.txt')).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByText('Projeto B.txt')).toBeInTheDocument(); + }); +} + +it('reprocessamento agenda novo refresh se a lista ainda estiver sendo carregada', async () => { + let finish!: (response: Response) => void; + let reads = 0; + vi.stubGlobal('fetch', vi.fn((url: RequestInfo | URL) => { + if (String(url).includes('/reprocess')) return json({}); + reads++; + if (reads === 2) return new Promise(resolve => { finish = resolve; }); + return listing([doc({ status_processamento: reads > 2 ? 'processado' : 'falha' })]); + })); + view(); + await screen.findByText('Escopo.pdf'); + fireEvent.click(screen.getByRole('button', { name: 'Atualizar' })); + await waitFor(() => expect(finish).toBeDefined()); + fireEvent.click(screen.getByRole('button', { name: 'Reprocessar' })); + await screen.findByText('Documento enviado para reprocessamento.'); + await act(async () => { finish(await listing([doc({status_processamento:'falha'})])); }); + expect(await screen.findByText('Disponível no acervo')).toBeInTheDocument(); + expect(reads).toBe(3); +}); diff --git a/frontend/src/views/documents/DocumentsView.tsx b/frontend/src/views/documents/DocumentsView.tsx index d0e07db..f0cfb36 100644 --- a/frontend/src/views/documents/DocumentsView.tsx +++ b/frontend/src/views/documents/DocumentsView.tsx @@ -31,19 +31,25 @@ function formatExtensions(limits: DocumentLimits): string { return limits.extensoes_permitidas.map((extension) => extension.slice(1).toUpperCase()).join(", "); } -export function DocumentsView({ - projectId, - projectName, - canWrite = false, - archived = false, - embedded = false, -}: { +type DocumentsProps = { projectId?: string; projectName?: string; canWrite?: boolean; archived?: boolean; embedded?: boolean; -}) { +}; + +export function DocumentsView(props: DocumentsProps) { + return ; +} + +function ProjectDocuments({ + projectId, + projectName, + canWrite = false, + archived = false, + embedded = false, +}: DocumentsProps) { const [items, setItems] = useState([]); const [limits, setLimits] = useState(null); const [nextCursor, setNextCursor] = useState(null); @@ -66,22 +72,52 @@ export function DocumentsView({ const heading = useRef(null); const uploadLock = useRef(false); const removalLock = useRef(false); + const scope = useRef(new AbortController()); + const pages = useRef(1); + const listLock = useRef(false); + const refreshPending = useRef(false); + const mutationRevision = useRef(0); + useEffect(() => { + const controller = new AbortController(); + scope.current = controller; + return () => controller.abort(); + }, []); + const requestSignal = (timeout = 15_000) => AbortSignal.any([scope.current.signal, AbortSignal.timeout(timeout)]); const refresh = useCallback(async (quiet = false) => { - if (!projectId) return; + if (!projectId || scope.current.signal.aborted) return; + if (listLock.current) { refreshPending.current = true; return; } + refreshPending.current = false; + listLock.current = true; + const signal = requestSignal(); + const revision = mutationRevision.current; if (quiet) setRefreshing(true); else setLoading(true); setLoadError(""); try { - const response = await listDocuments(projectId, { signal: AbortSignal.timeout(15_000) }); - setItems(response.items); + let cursor: string | undefined; + const loaded = new Map(); + let response; + for (let page = 0; page < pages.current; page++) { + response = await listDocuments(projectId, { cursor, signal }); + if (signal.aborted) return; + for (const item of response.items) loaded.set(item.id, item); + cursor = response.next_cursor ?? undefined; + if (!cursor) break; + } + if (!response || revision !== mutationRevision.current) return; + setItems([...loaded.values()]); setLimits(response.limites); setNextCursor(response.next_cursor); } catch { + if (scope.current.signal.aborted) return; setLoadError("Não foi possível carregar os documentos. Tente novamente."); } finally { + listLock.current = false; + if (scope.current.signal.aborted) return; setLoading(false); setRefreshing(false); + if (refreshPending.current) void refresh(true); } }, [projectId]); @@ -108,9 +144,15 @@ export function DocumentsView({ const reprocess = async (id: string) => { if (!projectId || reprocessing || archived || !canWrite) return; setReprocessing(id); - try { await reprocessDocument(projectId, id); setNotice("Documento enviado para reprocessamento."); await refresh(true); } - catch { setNotice("Não foi possível reprocessar o documento. Tente novamente."); } - finally { setReprocessing(null); } + const signal = requestSignal(); + try { + await reprocessDocument(projectId, id, signal); + if (signal.aborted) return; + mutationRevision.current += 1; + setNotice("Documento enviado para reprocessamento."); await refresh(true); + } + catch { if (!scope.current.signal.aborted) setNotice("Não foi possível reprocessar o documento. Tente novamente."); } + finally { if (!scope.current.signal.aborted) setReprocessing(null); } }; const hasProcessing = items.some((item) => item.nova_tentativa_pendente || ["pendente", "processando"].includes(item.status_processamento)); @@ -126,20 +168,28 @@ export function DocumentsView({ }, [target]); const loadMore = async () => { - if (!projectId || !nextCursor || loadingMore) return; + if (!projectId || !nextCursor || loadingMore || listLock.current) return; + listLock.current = true; + const signal = requestSignal(); setLoadingMore(true); setMoreError(""); try { - const response = await listDocuments(projectId, { cursor: nextCursor, signal: AbortSignal.timeout(15_000) }); + const response = await listDocuments(projectId, { cursor: nextCursor, signal }); + if (signal.aborted) return; + pages.current += 1; setItems((current) => { const known = new Set(current.map((item) => item.id)); return [...current, ...response.items.filter((item) => !known.has(item.id))]; }); setNextCursor(response.next_cursor); } catch { + if (scope.current.signal.aborted) return; setMoreError("Não foi possível carregar mais documentos."); } finally { + listLock.current = false; + if (scope.current.signal.aborted) return; setLoadingMore(false); + if (refreshPending.current) void refresh(true); } }; @@ -175,16 +225,21 @@ export function DocumentsView({ setUploading(true); setUploadError(""); setNotice(""); + const signal = requestSignal(120_000); try { - const created = await uploadDocument(projectId, selection); + const created = await uploadDocument(projectId, selection, signal); + if (signal.aborted) return; + mutationRevision.current += 1; setItems((current) => [created, ...current.filter((item) => item.id !== created.id)]); setNotice(created.armazenamento_pendente ? `O documento “${created.nome}” foi recebido. O armazenamento está sendo finalizado e a indexação depende da integração da S2-01.` : `O documento “${created.nome}” foi armazenado. A indexação do acervo depende da integração da S2-01.`); clearSelection(); } catch (error) { + if (scope.current.signal.aborted) return; setUploadError(describeUploadError(error)); } finally { + if (scope.current.signal.aborted) return; uploadLock.current = false; setUploading(false); } @@ -201,15 +256,20 @@ export function DocumentsView({ removalLock.current = true; setRemoving(true); setRemovalError(""); + const signal = requestSignal(); try { - await removeDocument(projectId, target.id); + await removeDocument(projectId, target.id, signal); + if (signal.aborted) return; + mutationRevision.current += 1; setItems((current) => current.filter((item) => item.id !== target.id)); setNotice(`O documento “${target.nome}” foi removido deste projeto.`); closeDialog(); heading.current?.focus(); } catch (error) { + if (scope.current.signal.aborted) return; setRemovalError(describeRemovalError(error)); } finally { + if (scope.current.signal.aborted) return; removalLock.current = false; setRemoving(false); } diff --git a/frontend/src/views/projects/RepoAnalyzerView.test.tsx b/frontend/src/views/projects/RepoAnalyzerView.test.tsx index a6929ce..38814af 100644 --- a/frontend/src/views/projects/RepoAnalyzerView.test.tsx +++ b/frontend/src/views/projects/RepoAnalyzerView.test.tsx @@ -58,7 +58,10 @@ it("alterna o relatório entre leitura e Markdown bruto", async () => { await screen.findByRole("heading", { name: "Título" }); fireEvent.click(screen.getByRole("button", { name: "Markdown" })); - await waitFor(() => expect(screen.getByRole("button", { name: "Markdown" })).toHaveAttribute("aria-pressed", "true")); + await waitFor( + () => expect(screen.getByRole("button", { name: "Markdown" })).toHaveAttribute("aria-pressed", "true"), + { timeout: 3000 }, + ); expect(screen.queryByRole("heading", { name: "Título" })).toBeNull(); expect(screen.getByText(/# Título/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Markdown" })).toHaveAttribute("aria-pressed", "true"); @@ -122,7 +125,7 @@ it("inicia a análise, seleciona a nova e exibe a mensagem do servidor quando re await waitFor(() => expect(screen.getByLabelText(/URL do repositório/)).toHaveValue("")); const [url, init] = request.mock.calls[2] as [string, RequestInit]; expect(url).toBe("/api/v1/projects/p-1/repo-analyses"); - expect(JSON.parse(String(init.body))).toEqual({ repositorio_url: "https://github.com/acme/novo" }); + expect(JSON.parse(String(init.body))).toEqual({ repositorio_url: "https://github.com/acme/novo", perfil: "quick" }); }); it("análise com falha mostra o motivo, permite repetir e alterna pelo histórico", async () => { @@ -146,7 +149,7 @@ it("análise com falha mostra o motivo, permite repetir e alterna pelo históric fireEvent.click(await screen.findByRole("button", { name: "Analisar novamente" })); await waitFor(() => expect(request).toHaveBeenCalledTimes(2)); const [, init] = request.mock.calls[1] as [string, RequestInit]; - expect(JSON.parse(String(init.body))).toEqual({ repositorio_url: "https://github.com/acme/api" }); + expect(JSON.parse(String(init.body))).toEqual({ repositorio_url: "https://github.com/acme/api", perfil: "quick" }); }); it("perfil sem permissão apenas consulta", async () => { @@ -155,3 +158,68 @@ it("perfil sem permissão apenas consulta", async () => { expect(await screen.findByText(/não pode iniciar novas/)).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Iniciar análise" })).toBeNull(); }); + +it("envia perfil rápido ao iniciar uma análise", async () => { + const created = analysis({ status: "iniciado", etapa: "queued", etapa_label: "Na fila", progresso: 0 }); + const request = vi.fn() + .mockImplementationOnce(() => json([])) + .mockImplementationOnce(() => json(created, 201)) + .mockImplementation(() => json([created])); + vi.stubGlobal("fetch", request); + render(); + await screen.findByText("Nenhuma análise realizada neste projeto."); + + fireEvent.change(screen.getByDisplayValue("Rápida · até 8 arquivos prioritários"), { target: { value: "balanced" } }); + fireEvent.change(screen.getByLabelText(/URL do repositório/), { target: { value: "https://github.com/acme/api" } }); + fireEvent.click(screen.getByRole("button", { name: "Iniciar análise" })); + await waitFor(() => expect(request).toHaveBeenCalledTimes(3)); + const [, options] = request.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(String(options.body))).toEqual({ repositorio_url: "https://github.com/acme/api", perfil: "balanced" }); +}); + +it("pausa uma análise e oferece retomada pelo checkpoint", async () => { + const paused = analysis({ status: "pausada", etapa: "files", etapa_label: "Analisando arquivos", progresso: 42, mensagem: "Progresso salvo." }); + const request = vi.fn() + .mockImplementationOnce(() => json([analysis()])) + .mockImplementationOnce(() => json(paused)) + .mockImplementationOnce(() => json([paused])); + vi.stubGlobal("fetch", request); + render(); + await screen.findByRole("button", { name: "Pausar" }); + fireEvent.click(screen.getByRole("button", { name: "Pausar" })); + + expect(await screen.findByRole("button", { name: "Retomar análise" })).toBeInTheDocument(); + expect(screen.getAllByText("Pausada").length).toBeGreaterThan(0); + expect(request.mock.calls[1][0]).toBe("/api/v1/projects/p-1/repo-analyses/a-1/pause"); +}); + +it("permite continuar uma análise que falhou depois de salvar progresso", async () => { + const failed = analysis({ status: "falha", etapa: "error", erro: "Limite temporário de saída", metadados: { files_total: 8, files_processed: 5, can_resume: true } }); + const resumed = analysis({ status: "iniciado", etapa: "queued", mensagem: "Retomando do último ponto salvo…" }); + const request = vi.fn() + .mockImplementationOnce(() => json([failed])) + .mockImplementationOnce(() => json(resumed)) + .mockImplementationOnce(() => json([resumed])); + vi.stubGlobal("fetch", request); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Continuar com progresso salvo" })); + expect(request.mock.calls[1][0]).toBe("/api/v1/projects/p-1/repo-analyses/a-1/resume"); + expect(await screen.findByText("Retomando do último ponto salvo…")).toBeInTheDocument(); +}); + +it("pede confirmação antes de cancelar e registra o estado cancelado", async () => { + const cancelled = analysis({ status: "cancelada", etapa: "files", etapa_label: "Analisando arquivos", progresso: 30, mensagem: "Análise cancelada pelo usuário." }); + const request = vi.fn() + .mockImplementationOnce(() => json([analysis()])) + .mockImplementationOnce(() => json(cancelled)) + .mockImplementationOnce(() => json([cancelled])); + vi.stubGlobal("fetch", request); + vi.spyOn(window, "confirm").mockReturnValue(true); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Cancelar análise" })); + + expect(window.confirm).toHaveBeenCalled(); + expect((await screen.findAllByText("Cancelada")).length).toBeGreaterThan(0); + expect(request.mock.calls[1][0]).toBe("/api/v1/projects/p-1/repo-analyses/a-1/cancel"); +}); diff --git a/frontend/src/views/projects/RepoAnalyzerView.tsx b/frontend/src/views/projects/RepoAnalyzerView.tsx index 0631813..6d2f931 100644 --- a/frontend/src/views/projects/RepoAnalyzerView.tsx +++ b/frontend/src/views/projects/RepoAnalyzerView.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { ApiError } from "../../api/api_auth"; import { serverMessage } from "../../api/api_errors"; -import { listRepoAnalyses, startRepoAnalysis, type RepoAnalysis } from "../../api/api_repo_analyzer"; +import { controlRepoAnalysis, type RepoAnalysisAction, type RepoAnalysisProfile, listRepoAnalyses, startRepoAnalysis, type RepoAnalysis } from "../../api/api_repo_analyzer"; import "../../assets/styles/repo-analyzer.css"; import { ANALYSIS_STAGES, @@ -63,12 +63,16 @@ export function RepoAnalyzerView({ projectId, canStart = true }: { projectId: st const [analyses, setAnalyses] = useState([]); const [selectedId, setSelectedId] = useState(null); const [repoUrl, setRepoUrl] = useState(""); + const [profile, setProfile] = useState("quick"); const [urlError, setUrlError] = useState(""); const [startError, setStartError] = useState(""); const [starting, setStarting] = useState(false); const [pollFailed, setPollFailed] = useState(false); const [refreshing, setRefreshing] = useState(false); + const [controlBusy, setControlBusy] = useState(false); + const [controlError, setControlError] = useState(""); const startingRef = useRef(false); + const requestKey = useRef<{ input: string; key: string } | null>(null); const mounted = useRef(true); const requestId = useRef(0); @@ -101,7 +105,7 @@ export function RepoAnalyzerView({ projectId, canStart = true }: { projectId: st void refresh(false); }, [refresh]); - const hasActive = analyses.some(isActive); + const hasActive = analyses.some(item => isActive(item) || (item.status === "concluido" && !item.relatorio_markdown)); const now = useNow(hasActive); useEffect(() => { @@ -110,13 +114,16 @@ export function RepoAnalyzerView({ projectId, canStart = true }: { projectId: st return () => window.clearInterval(timer); }, [hasActive, refresh]); - const launch = useCallback(async (url: string) => { + const launch = useCallback(async (url: string, selectedProfile: RepoAnalysisProfile = profile) => { if (startingRef.current) return; startingRef.current = true; setStarting(true); setStartError(""); try { - const created = await startRepoAnalysis(projectId, url); + const input = JSON.stringify([projectId, url, selectedProfile]); + if (requestKey.current?.input !== input) requestKey.current = { input, key: crypto.randomUUID() }; + const created = await startRepoAnalysis(projectId, url, selectedProfile, undefined, requestKey.current.key); + requestKey.current = null; if (!mounted.current) return; setRepoUrl(""); setAnalyses((previous) => [created, ...previous.filter((item) => item.id !== created.id)]); @@ -129,7 +136,25 @@ export function RepoAnalyzerView({ projectId, canStart = true }: { projectId: st startingRef.current = false; if (mounted.current) setStarting(false); } - }, [projectId, refresh]); + }, [projectId, profile, refresh]); + + const control = useCallback(async (analysisId: string, action: RepoAnalysisAction) => { + if (controlBusy) return; + if (action === "cancel" && !window.confirm("Cancelar esta análise? O progresso salvo será mantido, mas ela não poderá ser retomada.")) return; + setControlBusy(true); + setControlError(""); + try { + const updated = await controlRepoAnalysis(projectId, analysisId, action); + if (mounted.current) { + setAnalyses((previous) => previous.map((item) => item.id === updated.id ? updated : item)); + void refresh(true); + } + } catch (error) { + if (mounted.current) setControlError(error instanceof Error ? error.message : "Não foi possível controlar a análise. Tente novamente."); + } finally { + if (mounted.current) setControlBusy(false); + } + }, [controlBusy, projectId, refresh]); const submit = (event: React.FormEvent) => { event.preventDefault(); @@ -168,6 +193,13 @@ export function RepoAnalyzerView({ projectId, canStart = true }: { projectId: st onChange={(event) => { setRepoUrl(event.target.value); setUrlError(""); setStartError(""); }} /> + + + ) : ( @@ -195,7 +227,7 @@ export function RepoAnalyzerView({ projectId, canStart = true }: { projectId: st {pollFailed && Não foi possível atualizar agora. Tentaremos novamente em instantes.} {analyses.length === 0 ? ( -

Nenhuma análise realizada neste projeto.

+

Nenhuma análise realizada neste projeto.

) : (
    {analyses.map((item) => { @@ -223,7 +255,16 @@ export function RepoAnalyzerView({ projectId, canStart = true }: { projectId: st
    {selected ? ( - void launch(selected.repositorio_url)} /> + void launch(selected.repositorio_url)} + onControl={(action) => void control(selected.id, action)} + /> ) : ( void }) { +function AnalysisDetails({ analysis, now, canRetry, canControl, controlBusy, controlError, onRetry, onControl }: { + analysis: RepoAnalysis; + now: number; + canRetry: boolean; + canControl: boolean; + controlBusy: boolean; + controlError: string; + onRetry: () => void; + onControl: (action: RepoAnalysisAction) => void; +}) { const view = STATUS_VIEW[analysis.status] ?? STATUS_VIEW.iniciado; const active = isActive(analysis); + const canResume = analysis.status === "pausada" || analysis.metadados?.can_resume === true; const progress = analysis.progresso ?? 0; const stages = stageStates(analysis); const stats = readStats(analysis.metadados); @@ -267,7 +318,7 @@ function AnalysisDetails({ analysis, now, canRetry, onRetry }: { analysis: RepoA

    {repositoryLabel(analysis.repositorio_url)}

    -

    +

    Iniciada em {formatDateTime(analysis.created_at)} {analysis.autor_nome ? ` por ${analysis.autor_nome}` : ""} {duration !== null ? ` · duração ${formatDuration(duration)}` : ""} @@ -288,22 +339,41 @@ function AnalysisDetails({ analysis, now, canRetry, onRetry }: { analysis: RepoA ))} - {active && ( + {((active) || canResume || analysis.status === "cancelada") && (

    {analysis.etapa_label || "Na fila"} {progress}%
    - {analysis.mensagem &&

    {analysis.mensagem}

    } + {analysis.mensagem &&

    {analysis.mensagem}

    } {stats.filesTotal !== null && stats.filesProcessed !== null && ( -

    +

    {stats.filesProcessed} de {stats.filesTotal} arquivos analisados + {typeof analysis.metadados?.files_candidates === "number" && analysis.metadados.files_candidates > stats.filesTotal + ? ` · ${analysis.metadados.files_candidates - stats.filesTotal} fora do escopo` : ""} {stats.etaSeconds ? ` · restante estimado ${formatDuration(stats.etaSeconds)}` : ""}

    )} + {canControl && (active || canResume) && ( +
    + {(analysis.status === "em_execucao" || analysis.status === "iniciado") && ( + + )} + {canResume && ( + + )} + {(active || analysis.status === "pausada") && analysis.status !== "pausando" && analysis.status !== "cancelando" && ( + + )} +
    + )} + {analysis.status === "pausando" &&

    A pausa será aplicada após a chamada atual ao modelo.

    } + {analysis.status === "cancelando" &&

    O cancelamento será aplicado após a chamada atual ao modelo.

    } + {analysis.status === "falha" && canResume &&

    O checkpoint preservou os arquivos já concluídos; você pode continuar sem analisá-los novamente.

    }
    )} + {controlError && {controlError}} {stats.languages.length > 0 && (
      @@ -325,7 +395,7 @@ function AnalysisDetails({ analysis, now, canRetry, onRetry }: { analysis: RepoA )} {analysis.status === "concluido" && !report && ( - A análise foi concluída, mas o relatório ainda não está disponível. Use Atualizar em instantes. + A análise foi concluída, mas o relatório ainda não está disponível. A recuperação será tentada novamente automaticamente. )} {analysis.status === "concluido" && report && (