AUDIT / src /lib /backendClient.ts
Arypulka98's picture
feat(audit): deploy full backend cluster node (part 3)
95eb75a verified
Raw
History Blame Contribute Delete
10.2 kB
/**
* 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;
}