Spaces:
Sleeping
Sleeping
| /** | |
| * 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<T = unknown>( | |
| path: string, | |
| opts?: RequestInit, | |
| primaryBase?: string, | |
| secondaryBase?: string, | |
| timeoutMs = 12_000, | |
| ): Promise<T> { | |
| 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<T>; | |
| } 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<ExecResult>( | |
| "/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<ShellResult>( | |
| "/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<PipResult>( | |
| "/api/exec/pip", | |
| { method: "POST", body: JSON.stringify({ packages, session_id: getExecSessionId() }) }, | |
| EXEC_BACKEND_1, EXEC_BACKEND_2, | |
| ), | |
| getConversations: () => | |
| apiFetchWithFailover<ConvRow[]>("/api/conversations", {}, BACKEND_1, BACKEND_2), | |
| getMessages: (convId: string) => | |
| apiFetchWithFailover<MsgRow[]>(`/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<AgentTasksResponse>("/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<ShellResult>( | |
| "/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<ExecResult>( | |
| "/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<unknown>): 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; | |
| } | |