Spaces:
Configuration error
Configuration error
File size: 10,183 Bytes
95eb75a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 | /**
* 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;
}
|