/** * backendClient.ts — thin client per i backend API * * Architettura dual-backend [S73]: * - BACKEND (HF Space): conversazioni, memoria, AI (analyze-image) * - EXEC_BACKEND (HF Space): shell, pip, exec, terminal PTY * * S-DUAL-ENGINE: Supporto per failover automatico su account secondario (collaboratore). * Se il backend primario risponde con 429 (Rate Limit) o 503 (Overload), * il client tenta automaticamente l'endpoint secondario. */ import { ENV } from "@/config/env"; // S339: _INTERNAL_TOKEN per X-Internal-Token header (exec auth) const __INTERNAL_TOKEN: string = import.meta.env.VITE_INTERNAL_TOKEN ?? ""; void __INTERNAL_TOKEN; // S480: in produzione EXEC_BACKEND= viene injettato dal CF Worker (_worker.js) // VITE_EXEC_BACKEND_URL è il canale stesso-origin; fallback || !!BACKEND per proxy nativo const VITE_EXEC_BACKEND_URL_VAL_ = import.meta.env.VITE_EXEC_BACKEND_URL ?? ""; void VITE_EXEC_BACKEND_URL_VAL_; const BACKEND_1 = (ENV.BACKEND_URL ?? "").replace(/\/$/, ""); const BACKEND_2 = (ENV.BACKEND_URL_2 ?? "").replace(/\/$/, ""); const EXEC_BACKEND_1 = (ENV.EXEC_BACKEND_URL ?? "").replace(/\/$/, ""); const EXEC_BACKEND_2 = (ENV.EXEC_BACKEND_URL_2 ?? "").replace(/\/$/, ""); /** * S757: iOS Safari 12+ compat — sostituisce AbortSignal.timeout() */ // S481: boot probe AbortSignal.timeout(60_000) — warmup usa 60_000 ms (non 8_000) function _makeTimedSignal(timeoutMs: number): { signal: AbortSignal; cleanup: () => void } { const ctrl = new AbortController(); const timer = setTimeout( () => ctrl.abort(new DOMException('signal timed out', 'TimeoutError')), timeoutMs, ); return { signal: ctrl.signal, cleanup: () => clearTimeout(timer) }; } async function apiFetchWithFailover( path: string, opts?: RequestInit, primaryBase?: string, secondaryBase?: string, timeoutMs = 12_000, ): Promise { const attempt = async (base: string, tMs = timeoutMs) => { const { signal, cleanup } = _makeTimedSignal(tMs); try { const res = await fetch(`${base}${path}`, { ...opts, signal, // S339: X-Internal-Token aggiunto dal CF Worker (env.INTERNAL_TOKEN = _INTERNAL_TOKEN) headers: { "Content-Type": "application/json", ...(opts?.headers ?? {}) }, }); if (!res.ok) { // Se è un errore di rate limit o server sovraccarico, e abbiamo un backup, rilanciamo per il catch if ((res.status === 429 || res.status >= 500) && secondaryBase && base !== secondaryBase) { throw { status: res.status, isFailoverCandidate: true }; } throw new Error(`${res.status} ${res.statusText} — ${path}`); } return res.json() as Promise; } finally { cleanup(); } }; try { return await attempt(primaryBase || ""); } catch (err: unknown) { const isFailover = (err as { isFailoverCandidate?: boolean }).isFailoverCandidate; // Failover: 429/5xx dal server O errore di rete (TypeError: Failed to fetch) if (secondaryBase && (isFailover || err instanceof TypeError)) { if (import.meta.env.DEV) { const status = (err as { status?: number }).status; console.warn(`[Dual-Engine] Primary failed${status ? ` (${status})` : " (network)"} — switching to secondary`); // check-console-log: dev-only } return await attempt(secondaryBase, 10_000); } throw err; } } // ── Conversation row (snake_case matches backend) ───────────────────────────── export interface ConvRow { id: string; title: string; created_at: number; updated_at: number; } export interface MsgRow { id: string; conversation_id: string; role: string; content: string; created_at: number; error?: boolean; steps?: string | null; agent_status?: string | null; } export interface ShellResult { stdout: string; stderr: string; exit_code: number; partial?: boolean; } export interface PipResult { installed?: string[]; stdout?: string; stderr?: string; exit_code: number; error?: string; } export interface ExecResult { stdout: string; stderr: string; exit_code: number; language?: string; truncated?: boolean; partial?: boolean; } export interface AgentTaskRow { taskId: string; goal: string; status: string; maxSteps: number; createdAt: number; ageMs: number; source: 'memory' | 'supabase'; isLive: boolean; } export interface AgentTasksResponse { count: number; memory: number; supabase: number; tasks: AgentTaskRow[]; } export interface MemRow { key: string; value: string; category?: string; createdAt?: number; updatedAt?: number; } export function getExecSessionId(): string { try { const KEY = "agente_exec_session_id"; let id = localStorage.getItem(KEY); if (!id) { id = crypto.randomUUID(); localStorage.setItem(KEY, id); } return id; } catch { return ""; } } // ── Public API ──────────────────────────────────────────────────────────────── export const backend = { isAvailable: () => !!BACKEND_1, // ── Exec Engine methods (delegated from agentLoop split modules) ──────────── isExecAvailable: () => !!(EXEC_BACKEND_1 || EXEC_BACKEND_2), execCode: (code: string, language: string, timeoutSec = 30) => apiFetchWithFailover( "/api/exec/run", { method: "POST", body: JSON.stringify({ code, language, timeout: timeoutSec, session_id: getExecSessionId() }) }, EXEC_BACKEND_1, EXEC_BACKEND_2, ), executeShell: (command: string, timeoutSec = 15, session_id?: string) => apiFetchWithFailover( "/api/exec/shell", { method: "POST", body: JSON.stringify({ command, timeout: timeoutSec, session_id: session_id ?? getExecSessionId() }) }, EXEC_BACKEND_1, EXEC_BACKEND_2, ), pipInstall: (packages: string[]) => apiFetchWithFailover( "/api/exec/pip", { method: "POST", body: JSON.stringify({ packages, session_id: getExecSessionId() }) }, EXEC_BACKEND_1, EXEC_BACKEND_2, ), getConversations: () => apiFetchWithFailover("/api/conversations", {}, BACKEND_1, BACKEND_2), getMessages: (convId: string) => apiFetchWithFailover(`/api/conversations/${convId}/messages`, {}, BACKEND_1, BACKEND_2), deleteConversation: (convId: string) => apiFetchWithFailover<{ok:boolean}>(`/api/conversations/${convId}`, { method: "DELETE" }, BACKEND_1, BACKEND_2), analyzeImage: (dataUrl: string, filename: string) => apiFetchWithFailover<{ok:boolean; description:string; tags:string[]; mood:string; text_in_image?:string; error?:string}>( "/api/ai/analyze-image", { method: "POST", body: JSON.stringify({ image: dataUrl, filename }) }, BACKEND_1, BACKEND_2 ), getTasks: () => apiFetchWithFailover("/api/agent/tasks", {}, BACKEND_1, BACKEND_2), listMemory: () => apiFetchWithFailover<{ entries: MemRow[] }>("/api/memory", {}, BACKEND_1, BACKEND_2), upsertConversation: (row: { id: string; title: string; created_at: number; updated_at: number }) => apiFetchWithFailover<{ ok: boolean }>("/api/conversations", { method: "POST", body: JSON.stringify(row) }, BACKEND_1, BACKEND_2), upsertMessages: (conversationId: string, rows: unknown[]) => apiFetchWithFailover<{ ok: boolean }>(`/api/conversations/${conversationId}/messages`, { method: "POST", body: JSON.stringify({ messages: rows }) }, BACKEND_1, BACKEND_2), listConversations: () => apiFetchWithFailover<{ conversations: ConvRow[] }>("/api/conversations/all", {}, BACKEND_1, BACKEND_2), listMessages: (conversationId: string) => apiFetchWithFailover<{ messages: MsgRow[] }>(`/api/conversations/${conversationId}/messages/all`, {}, BACKEND_1, BACKEND_2), startDevServer: (sessionId: string, command: string, cwd: string) => apiFetchWithFailover<{ ok: boolean; url?: string; error?: string }>( "/api/exec/dev-server", { method: "POST", body: JSON.stringify({ session_id: sessionId, command, cwd }) }, EXEC_BACKEND_1, EXEC_BACKEND_2, ), getCloudPreviewUrl: (sessionId: string, subPath = "") => `${EXEC_BACKEND_1}/api/exec/preview/${sessionId}${subPath}`, }; export const execEngine = { runCommand: (command: string, cwd?: string) => apiFetchWithFailover( "/api/exec/shell", { method: "POST", body: JSON.stringify({ command, cwd, session_id: getExecSessionId() }) }, EXEC_BACKEND_1, EXEC_BACKEND_2 ), runCode: (code: string, language: string) => apiFetchWithFailover( "/api/exec/run", { method: "POST", body: JSON.stringify({ code, language, session_id: getExecSessionId() }) }, EXEC_BACKEND_1, EXEC_BACKEND_2 ), }; // ── P33: Backend Warming & Health EMA ───────────────────────────────────────── // S381 Fix 7: EMA health score con threshold 0.4 e fallback a isBackendWarm() let _warm = false; let _warmAt = 0; const WARM_TTL = 90_000; let _backendScore = 1.0; // EMA salute backend (S381-F7) let _scoreUpdatedAt = 0; // timestamp ultimo aggiornamento score export function markBackendWarm(warm: boolean): void { _warm = warm; _warmAt = warm ? Date.now() : _warmAt; } export function isBackendWarm(): boolean { return _warm && (Date.now() - _warmAt) < WARM_TTL; } export function recordBackendResult(success: boolean, _latencyMs?: number): void { // S381-F7: formula EMA score = score*0.8 + (success ? 0.2 : 0) _backendScore = _backendScore * 0.8 + (success ? 0.2 : 0); _scoreUpdatedAt = Date.now(); markBackendWarm(success); } export function getBackendHealthScore(): number { return _backendScore; } export function bgSync(promise: Promise): void { promise.catch(() => {}); } export function isBackendHealthy(): boolean { // S381-F7: usa isBackendWarm() se score mai aggiornato (primo avvio) if (_scoreUpdatedAt === 0) return isBackendWarm(); return _backendScore > 0.4; }