Spaces:
Sleeping
Sleeping
| /** | |
| * codeRunner.ts — Code runner tools | |
| * | |
| * Contiene: runCodeSandbox, runViaPyodideWorker, Pyodide worker via WorkerManager, | |
| * tool implementations: run_code, execute_shell, pip_install, run_python, | |
| * zip_project, profile_code, read_pdf, send_notification | |
| * | |
| * S135 — Integrazione runtime/WorkerManager per py worker: | |
| * - WorkerManager gestisce lifecycle completo: spawn, ping/pong health check (60s), | |
| * auto-respawn dopo 3 ping falliti, idle-terminate | |
| * - codeRunner.ts gestisce message routing via _pyListeners Map | |
| * - WorkerManager usa w.onmessage internamente (ping/pong) — | |
| * codeRunner usa addEventListener per coesistere senza conflitti | |
| * - onStatusChange subscription: re-attacca handler dopo ogni respawn | |
| * senza che codeRunner.ts gestisca alcun lifecycle manuale | |
| * - pyodideReady in RuntimeDiagnosticsPanel ora riporta stato reale | |
| */ | |
| import { vfsAsync } from "../vfsDb"; | |
| import { vfs } from "../vfs"; | |
| import { debugLoop } from "../debugLoop"; | |
| import { agentMemory } from "../agentMemory"; | |
| import { adaptiveSolver } from "../adaptiveSolver"; | |
| import { recordOutcomeAsync } from "../selfLearningAsync"; // FIX22: S649 — era dynamic import+sync (blocca UI ~200ms iPhone) | |
| import { usePythonVarsStore } from "@/store/pythonVarsStore"; | |
| import type { PythonVar } from "@/store/pythonVarsStore"; | |
| import { workerManager, WorkerManager } from "../runtime/WorkerManager"; | |
| import type { WorkerLike } from "../runtime/WorkerManager"; | |
| import { eventRuntime } from "@/core/events"; // S768: partial_output event | |
| // ─── Tool dispatcher ────────────────────────────────────────────────────────── | |
| export async function tryExecute(name: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<string | null> { | |
| if (name === "run_code") { | |
| const { code, language, _retry = false } = args as { code: string; language?: string; _retry?: boolean }; | |
| // S74: Python/bash/ts → usa exec backend HF Space /api/exec se disponibile | |
| const _lang = (language ?? "javascript").toLowerCase(); | |
| if (!["javascript", "js"].includes(_lang)) { | |
| const { backend } = await import("../backendClient"); | |
| if (backend.isExecAvailable()) { | |
| // P1: Pre-exec Python dep detection — installa moduli non-stdlib prima dell'esecuzione | |
| // Elimina l'iterazione sprecata su ModuleNotFoundError | |
| if (_lang === "python" || _lang === "py") { | |
| const _PYLIB = new Set(["os","sys","json","re","math","datetime","collections","itertools", | |
| "functools","pathlib","typing","abc","io","time","random","hashlib","urllib","http", | |
| "threading","subprocess","traceback","inspect","copy","string","struct","base64", | |
| "contextlib","dataclasses","enum","logging","tempfile","shutil","glob","csv","xml", | |
| "html","email","socket","ssl","unittest","gc","weakref","queue","heapq","bisect", | |
| "builtins","operator","textwrap","warnings","keyword","ast","types","decimal", | |
| "statistics","cmath","numbers","array","zlib","pickle","shelve","sqlite3", | |
| "configparser","argparse","platform","signal","mimetypes","calendar", | |
| "__future__","typing_extensions","dataclasses","pprint","reprlib"]); | |
| const _pyPkgs = Array.from(new Set( | |
| (code.match(/^(?:import|from)\s+(\w+)/gm) ?? []) | |
| .map((m: string) => m.split(/\s+/)[1]) | |
| .filter((mod: string) => mod && !_PYLIB.has(mod)) | |
| )); | |
| if (_pyPkgs.length) { | |
| try { await backend.execCode(`pip install ${_pyPkgs.join(" ")} -q 2>&1 | tail -1`, "bash"); } catch { /* best-effort — non-blocking */ } | |
| } | |
| } | |
| try { | |
| const r = await backend.execCode(code, _lang); | |
| // S768: partial output — emetti evento | |
| // S769: persist al VFS per ispezione post-chip | |
| if (r.partial) { | |
| const _partialSnip = (r.stdout ?? "").slice(0, 300); | |
| try { eventRuntime.emit({ kind: "partial_output", tool: "run_code", partialOut: _partialSnip }); } catch { /* non-blocking */ } | |
| const _ts = Date.now(); | |
| vfsAsync.write( | |
| `.partial_out_${_ts}.txt`, | |
| `[S769] tool: run_code\ntime: ${new Date(_ts).toISOString()}\n\n${r.stdout ?? ""}`, | |
| ).catch(() => {}); | |
| } | |
| const out = [r.stdout, r.stderr].filter(Boolean).join("\n").trim(); | |
| return out || `[OK (exit ${r.exit_code}) — ${_lang}]`; | |
| } catch { /* fallback a browser sandbox */ } | |
| } | |
| } | |
| const result = await runCodeSandbox(code); | |
| if (result.startsWith("❌") && !_retry) { | |
| // RT-10: se il segnale è già abortito, non avviare auto-fix + secondo sandbox — | |
| // evita esecuzione fantasma di 70s in background dopo che il timeout esterno (65s) | |
| // ha già restituito "❌ [TIMEOUT]" al loop. | |
| if (signal?.aborted) return result; | |
| const taskId = crypto.randomUUID().slice(0, 8); | |
| const fixed = await debugLoop._askLLMFix(code, result.replace("❌ ", ""), "javascript", taskId); | |
| if (fixed && fixed.trim() !== code.trim()) { | |
| // RT-10: secondo check prima del secondo runCodeSandbox | |
| if (signal?.aborted) return result; | |
| const retryResult = await runCodeSandbox(fixed); | |
| if (!retryResult.startsWith("❌")) return `[auto-fix]→${retryResult}`; | |
| const solved = await adaptiveSolver.solve(retryResult, fixed, "run_code"); | |
| if (solved.success) { | |
| // S350: impara dai fix riusciti — pattern salvato per sessioni future | |
| try { recordOutcomeAsync({ task: `run_code:${solved.strategy}`, output: solved.output.slice(0, 300), success: true, confidence: 0.85, toolsUsed: ["run_code"] }); } catch { /* non-blocking */ } // FIX22 | |
| return `[adaptive-solver: ${solved.strategy}] ${solved.description}\n${solved.output}`; | |
| } | |
| return `${result}\n\n⚠️ Auto-fix tentato ma ancora in errore:\n${retryResult}\n\n💡 ${solved.description}`; | |
| } | |
| const solved = await adaptiveSolver.solve(result, code, "run_code"); | |
| if (solved.success) { | |
| // S350: impara dai fix riusciti — pattern salvato per sessioni future | |
| try { recordOutcomeAsync({ task: `run_code:${solved.strategy}`, output: solved.output.slice(0, 300), success: true, confidence: 0.85, toolsUsed: ["run_code"] }); } catch { /* non-blocking */ } // FIX22 | |
| return `[adaptive-solver: ${solved.strategy}] ${solved.description}\n${solved.output}`; | |
| } | |
| } | |
| return result; | |
| } | |
| if (name === "execute_shell") { | |
| const { command, timeout = 15, session_id } = args as { command: string; timeout?: number; session_id?: string }; | |
| const { backend } = await import("../backendClient"); | |
| // S144-Fix5: messaggio contestuale quando nessun backend è configurato. | |
| // EXEC_BACKEND fa già fallback su BACKEND (VITE_BACKEND_URL), quindi | |
| // isExecAvailable() è false SOLO se entrambe le env var sono vuote. | |
| if (!backend.isExecAvailable()) { | |
| const isPyCmd = /^\s*(python3?|pip3?)\s/.test(command); | |
| if (isPyCmd) return "💡 Backend non configurato. Per eseguire Python usa il tool **run_python** — funziona anche offline via Pyodide."; | |
| return "❌ execute_shell: nessun backend configurato. Imposta **VITE_BACKEND_URL** nelle env Vercel puntando all\'HF Space."; | |
| } | |
| try { | |
| // S694-persist: session_id da args (npm_install/npm_run propagano) o getExecSessionId() internamente | |
| // Cap 300s: npm install/build richiedono fino a 120s; il server HF Space ha i propri resource limits | |
| const r = await backend.executeShell(command, Math.min(Number(timeout) || 15, 300), session_id); | |
| // S768: partial output — emetti evento | |
| // S769: persist al VFS | |
| if (r.partial) { | |
| const _partialSnip = (r.stdout ?? "").slice(0, 300); | |
| try { eventRuntime.emit({ kind: "partial_output", tool: "execute_shell", partialOut: _partialSnip }); } catch { /* non-blocking */ } | |
| const _ts = Date.now(); | |
| vfsAsync.write( | |
| `.partial_out_${_ts}.txt`, | |
| `[S769] tool: execute_shell\ntime: ${new Date(_ts).toISOString()}\n\n${r.stdout ?? ""}`, | |
| ).catch(() => {}); | |
| } | |
| let out = "```\n"; | |
| if (r.stdout) out += r.stdout; | |
| if (r.stderr) out += `\n[stderr] ${r.stderr}`; | |
| out += `\n[exit ${r.exit_code}]` + "\n```"; | |
| return out; | |
| } catch (e) { | |
| const errMsg = e instanceof Error ? e.message : String(e); | |
| // S144-Fix5: se il backend HF Space non ha /api/execute-shell (404/405), | |
| // re-routing automatico per comandi Python via Pyodide. | |
| if (/404|405|not.?found|method.?not.?allowed/i.test(errMsg)) { | |
| // Caso: python3 -c "codice" → esegui via run_python | |
| const pyInlineMatch = command.match(/python3?\s+-c\s+["'\`]([\s\S]+)["'\`]/); | |
| if (pyInlineMatch) { | |
| return runViaPyodideWorker(pyInlineMatch[1]); | |
| } | |
| // Caso: comando shell generico non supportato | |
| return "❌ execute_shell: il backend HF Space non supporta comandi shell diretti. Usa **run_python** per codice Python, oppure configura un Exec Engine dedicato (VITE_EXEC_BACKEND_URL)."; | |
| } | |
| return `❌ execute_shell: ${errMsg}`; | |
| } | |
| } | |
| if (name === "pip_install") { | |
| const { packages } = args as { packages: string[] }; | |
| if (!Array.isArray(packages) || packages.length === 0) return "❌ pip_install: fornisci almeno un pacchetto."; | |
| const { backend } = await import("../backendClient"); | |
| // S144-Fix5: suggerisci micropip (Pyodide) se nessun backend disponibile | |
| const _micropipHint = (pkgs: string[]) => | |
| `💡 pip_install non disponibile senza backend. In Pyodide usa micropip:\n\`\`\`python\nimport micropip\nawait micropip.install([${pkgs.map(p => `"${p}"`).join(", ")}])\n\`\`\``; | |
| if (!backend.isExecAvailable()) return _micropipHint(packages); | |
| try { | |
| const r = await backend.pipInstall(packages); | |
| if (r.exit_code === 0) return `✅ Installati: ${(r.installed ?? packages).join(", ")}`; | |
| return `❌ pip install fallito (exit ${r.exit_code}):\n${r.stderr ?? r.error ?? "errore sconosciuto"}`; | |
| } catch (e) { | |
| const errMsg = e instanceof Error ? e.message : String(e); | |
| // S144-Fix5: 404 sul backend → suggerisci micropip | |
| if (/404|405|not.?found/i.test(errMsg)) return _micropipHint(packages); | |
| return `❌ pip_install: ${errMsg}`; | |
| } | |
| } | |
| if (name === "run_python") { | |
| const { code, packages = [] } = args as { code: string; packages?: string[] }; | |
| return await runViaPyodideWorker(code, packages, signal); | |
| } | |
| if (name === "zip_project") { | |
| const { paths, filename = "project.zip" } = args as { paths?: string[]; filename?: string }; | |
| const allFiles = await vfsAsync.list() as Array<{ path: string }>; | |
| const targets = paths && paths.length > 0 | |
| ? allFiles.filter(f => paths.some(p => f.path.startsWith(p) || f.path === p)) | |
| : allFiles; | |
| if (targets.length === 0) return "❌ zip_project: nessun file nel VFS da zippare."; | |
| const fileContentsRaw = await Promise.all(targets.map(async f => ({ path: f.path, content: await vfsAsync.read(f.path) }))); | |
| const fileContents = fileContentsRaw.filter((f): f is { path: string; content: string } => f.content !== null); | |
| const zipCode = ` | |
| const JSZip = (await importModule('jszip')).default; | |
| const zip = new JSZip(); | |
| const files = ${JSON.stringify(fileContents)}; | |
| for (const f of files) zip.file(f.path.replace(/^\\//, ''), f.content); | |
| const blob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); a.href = url; a.download = ${JSON.stringify(filename)}; a.click(); | |
| setTimeout(() => URL.revokeObjectURL(url), 5000); | |
| console.log('📦 ZIP scaricato: ${filename} (' + files.length + ' file)'); // check-console-log: ok | |
| `; | |
| return await runCodeSandbox(zipCode.trim()); | |
| } | |
| if (name === "profile_code") { | |
| const { code, iterations = 100, warmup = 10 } = args as { code: string; iterations?: number; warmup?: number }; | |
| const iters = Math.min(Number(iterations) || 100, 10000); | |
| const warmupN = Math.min(Number(warmup) || 10, 100); | |
| const profileCode = ` | |
| ${code} | |
| if (typeof main !== 'function') { console.log('❌ Definisci una funzione main() nel codice da profilare.'); } // check-console-log: ok | |
| else { | |
| const WARMUP = ${warmupN}; | |
| const ITERS = ${iters}; | |
| // Warmup | |
| for (let i = 0; i < WARMUP; i++) await main(); | |
| // Measure | |
| const times = []; | |
| for (let i = 0; i < ITERS; i++) { | |
| const t0 = performance.now(); | |
| await main(); | |
| times.push(performance.now() - t0); | |
| } | |
| times.sort((a,b) => a-b); | |
| const sum = times.reduce((a,b)=>a+b,0); | |
| const avg = sum/times.length; | |
| const min = times[0]; | |
| const max = times[times.length-1]; | |
| const p50 = times[Math.floor(times.length*0.5)]; | |
| const p95 = times[Math.floor(times.length*0.95)]; | |
| const p99 = times[Math.floor(times.length*0.99)]; | |
| const opsPerSec = avg > 0 ? (1000/avg).toFixed(0) : '∞'; | |
| console.log('⚡ Profiler Results (' + ITERS + ' iterazioni, ' + WARMUP + ' warmup)'); // check-console-log: ok | |
| console.log('avg: ' + avg.toFixed(4) + ' ms'); // check-console-log: ok | |
| console.log('min: ' + min.toFixed(4) + ' ms'); // check-console-log: ok | |
| console.log('max: ' + max.toFixed(4) + ' ms'); // check-console-log: ok | |
| console.log('p50: ' + p50.toFixed(4) + ' ms'); // check-console-log: ok | |
| console.log('p95: ' + p95.toFixed(4) + ' ms'); // check-console-log: ok | |
| console.log('p99: ' + p99.toFixed(4) + ' ms'); // check-console-log: ok | |
| console.log('ops/s: ' + opsPerSec); // check-console-log: ok | |
| } | |
| `; | |
| return await runCodeSandbox(profileCode.trim()); | |
| } | |
| if (name === "read_pdf") { | |
| const { url, max_pages = 10 } = args as { url: string; max_pages?: number }; | |
| const maxP = Math.min(Number(max_pages) || 10, 30); | |
| const pdfCode = ` | |
| const pdfjsLib = await importModule('pdfjs-dist/legacy/build/pdf'); | |
| pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.10.38/pdf.worker.min.mjs'; | |
| const pdf = await pdfjsLib.getDocument(${JSON.stringify(url)}).promise; | |
| const numPages = Math.min(pdf.numPages, ${maxP}); | |
| const pages = []; | |
| for (let i = 1; i <= numPages; i++) { | |
| const page = await pdf.getPage(i); | |
| const content = await page.getTextContent(); | |
| const text = content.items.map(item => ('str' in item ? item.str : '')).join(' ').trim(); | |
| if (text) pages.push('--- Pagina ' + i + ' ---\\n' + text); | |
| } | |
| if (!pages.length) { console.log('❌ Nessun testo estraibile (PDF scansionato o protetto).'); } // check-console-log: ok | |
| else { console.log('📄 PDF: ' + pdf.numPages + ' pagine totali, estratte: ' + numPages + '\\n\\n' + pages.join('\\n\\n').slice(0, 8000)); } // check-console-log: ok | |
| `; | |
| return await runCodeSandbox(pdfCode.trim()); | |
| } | |
| return null; | |
| } | |
| // ─── Code Sandbox ───────────────────────────────────────────────────────────── | |
| export async function runCodeSandbox(code: string): Promise<string> { | |
| const logs: string[] = []; | |
| const logFn = (...a: unknown[]) => logs.push(a.map(x => typeof x === "string" ? x : JSON.stringify(x, null, 2)).join(" ")); | |
| const downloadFn = (content: string, filename = "file.txt", mimeType = "text/plain") => { | |
| const blob = new Blob([content], { type: mimeType }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; a.download = filename; a.click(); | |
| setTimeout(() => URL.revokeObjectURL(url), 5000); | |
| logs.push(`📁 Download avviato: ${filename}`); | |
| }; | |
| // I-a fix S178: generatePDF scaricava HTML rinominato .pdf — ora apre finestra stampa | |
| // (browser → Stampa → "Salva come PDF" = vero PDF) e scarica anche l'HTML come fallback | |
| const generatePDF = async (html: string, filename = "documento.pdf") => { | |
| const fullHtml = `<!DOCTYPE html><html><head><title>${filename}</title><style> | |
| body{font-family:sans-serif;max-width:800px;margin:2rem auto;line-height:1.6} | |
| @media print{body{margin:0}button{display:none}} | |
| </style></head><body> | |
| <button onclick="window.print()" style="margin-bottom:1rem;padding:.5rem 1rem;cursor:pointer">🖨️ Stampa / Salva come PDF</button> | |
| ${html} | |
| </body></html>`; | |
| const blob = new Blob([fullHtml], { type: "text/html" }); | |
| const url = URL.createObjectURL(blob); | |
| const win = window.open(url, "_blank"); | |
| if (win) { setTimeout(() => win.print(), 800); } | |
| else { | |
| const a = document.createElement("a"); a.href = url; a.download = filename.replace(/\.pdf$/i, ".html"); a.click(); | |
| logs.push(`⚠️ generatePDF: popup bloccato — scaricato HTML (${filename.replace(/\.pdf$/i,".html")}). Apri il file e usa Stampa → Salva come PDF.`); | |
| } | |
| setTimeout(() => URL.revokeObjectURL(url), 30000); | |
| logs.push(`🖨️ PDF-print: "${filename}" — finestra stampa aperta. Scegli "Salva come PDF" nel dialogo.`); | |
| }; | |
| const globals: Record<string, unknown> = { | |
| console: { log: logFn, warn: logFn, error: logFn, info: logFn }, | |
| fetch, vfs, memory: agentMemory, | |
| download: downloadFn, generatePDF, | |
| uuid: () => crypto.randomUUID(), | |
| hash: async (s: string, algo = "SHA-256") => { | |
| const buf = await crypto.subtle.digest(algo, new TextEncoder().encode(s)); | |
| return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join(""); | |
| }, | |
| b64encode: (s: string) => btoa(unescape(encodeURIComponent(s))), | |
| b64decode: (s: string) => decodeURIComponent(escape(atob(s))), | |
| csvParse: (t: string) => t.trim().split(/\r?\n/).map(l => { | |
| const r: string[] = []; let f = "", q = false; | |
| for (const c of l) { | |
| if (c === '"') q = !q; | |
| else if (c === ',' && !q) { r.push(f); f = ""; } | |
| else f += c; | |
| } | |
| r.push(f); return r; | |
| }), | |
| importModule: (spec: string) => import(/* @vite-ignore */ `https://esm.sh/${spec}`), | |
| // OPT: require polyfill — redirige al sistema esm.sh con messaggio utile | |
| // Evita ReferenceError generico "require is not defined" che confonde l'LLM | |
| require: (spec: string) => { | |
| throw new Error( | |
| `require("${spec}") non disponibile nel browser sandbox.\n` + | |
| `Usa invece: const mod = await importModule("${spec}");` | |
| ); | |
| }, | |
| chart: (type: string, labels: string[], values: number[], title?: string) => { logs.push(`📊 Chart[${type}] "${title ?? ""}" — ${labels.slice(0,4).join(", ")}…`); }, | |
| sleep: (ms: number) => new Promise<void>(r => setTimeout(r, ms)), | |
| atob, btoa, URL, Promise, JSON, Math, Date, Object, Array, String, Number, Boolean, | |
| parseInt, parseFloat, isNaN, isFinite, encodeURIComponent, decodeURIComponent, | |
| setTimeout, clearTimeout, setInterval, clearInterval, TextEncoder, TextDecoder, | |
| crypto, Blob, ArrayBuffer, Uint8Array, Int8Array, Float32Array, Float64Array, | |
| }; | |
| try { | |
| const AsyncFunction = Object.getPrototypeOf(async function () { }).constructor as new (...args: string[]) => (...args: unknown[]) => Promise<unknown>; | |
| const fn = new AsyncFunction(...Object.keys(globals), `"use strict";\n${code}`); | |
| const timeoutP = new Promise<void>((_, rej) => setTimeout(() => rej(new Error("Sandbox timeout (30s)")), 30_000)); | |
| await Promise.race([fn(...Object.values(globals)), timeoutP]); | |
| const out = logs.join("\n") || "(nessun output)"; | |
| return out.length > 4000 ? out.slice(0, 4000) + "\n...(troncato)" : out; | |
| } catch (err) { | |
| return `❌ ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`; | |
| } | |
| } | |
| // ─── Pyodide Worker via runtime/WorkerManager ───────────────────────────────── | |
| // | |
| // S135: WorkerManager (runtime/WorkerManager.ts) gestisce il lifecycle completo: | |
| // • Spawn lazy alla prima run_python | |
| // • Ping/pong health check ogni 60s | |
| // • Auto-respawn automatico dopo 3 ping falliti (Safari iOS silent kill) | |
| // • Idle auto-terminate dopo 60s di inattività | |
| // • Status reporting per RuntimeDiagnosticsPanel (pyodideReady ora reale) | |
| // | |
| // Coesistenza con WorkerManager.onmessage: | |
| // WorkerManager usa w.onmessage = ... per ping/pong (ready/pong). | |
| // codeRunner usa w.addEventListener("message", ...) per coesistere senza | |
| // sovrascrivere l'handler interno. Entrambi i listener ricevono tutti i | |
| // messaggi; _pyMessageHandler ignora ready/pong, WorkerManager ignora il resto. | |
| // | |
| // Respawn handling: | |
| // onStatusChange subscription (module-level, registrata una volta): | |
| // - health "dead" → pulisce _pyWorkerRef e pending _pyListeners | |
| // - health "alive" → riattacca _pyMessageHandler al nuovo worker | |
| // | |
| // _pyStatus è settato da agentLoop via setPyodideStatus() prima di ogni runAgentLoop. | |
| type _PyWorkerMsg = { | |
| type: string; | |
| id?: string; | |
| text?: string; | |
| message?: string; | |
| exitCode?: number; | |
| progress?: number; | |
| vars?: PythonVar[]; | |
| }; | |
| let _pyStatus: ((s: string) => void) | null = null; | |
| const _pyListeners = new Map<string, (_msg: _PyWorkerMsg) => void>(); | |
| /** Riferimento al worker attivo corrente — null se non ancora spawned o dopo terminate. */ | |
| let _pyWorkerRef: WorkerLike | null = null; // S143-Bug4: tipo allineato a WorkerLike | |
| // ─── Message handler ────────────────────────────────────────────────────────── | |
| // Funzione named e stabile — addEventListener/removeEventListener richiedono | |
| // la stessa referenza. Non deve essere ricreata tra respawn. | |
| function _pyMessageHandler(e: MessageEvent<_PyWorkerMsg>): void { | |
| const msg = e.data; | |
| if (msg.type === "loading") { | |
| const pct = (msg as { type: string; progress?: number }).progress ?? 0; | |
| if (_pyStatus) { | |
| if (pct <= 0) _pyStatus("🐍 Avvio Python…"); | |
| else if (pct < 60) _pyStatus("🐍 Caricamento Python…"); | |
| else _pyStatus("🐍 Python quasi pronto…"); | |
| } | |
| return; | |
| } | |
| // "ready" e "pong" sono gestiti da WorkerManager.onmessage internamente — | |
| // questo handler li ignora per evitare doppia elaborazione. | |
| if (msg.type === "ready" || msg.type === "pong") return; | |
| // S71: variabili Python estratte post-run → aggiorna store | |
| if (msg.type === "vars" && (msg as { type: string; vars?: PythonVar[] }).vars?.length) { | |
| usePythonVarsStore.getState().updateVars( | |
| (msg as { type: string; vars: PythonVar[] }).vars, | |
| ); | |
| return; | |
| } | |
| // Messaggi per-esecuzione → routing per id | |
| if (msg.id) _pyListeners.get(msg.id)?.(msg); | |
| } | |
| // ─── Attacca handler al worker (idempotente) ────────────────────────────────── | |
| // Usa addEventListener → coesiste con WorkerManager.onmessage senza sovrascriverlo. | |
| // removeEventListener prima di add → garantisce unicità anche su chiamate ripetute. | |
| // S143-Bug4: WorkerLike ora include addEventListener/removeEventListener. | |
| // Rimosso il cast fragile "as Worker" — wl è già tipato correttamente. | |
| function _attachPyHandler(wl: WorkerLike): void { | |
| wl.removeEventListener("message", _pyMessageHandler); | |
| wl.addEventListener("message", _pyMessageHandler); | |
| _pyWorkerRef = wl; | |
| } | |
| // ─── Subscription unica ai lifecycle events del worker ─────────────────────── | |
| // Registrata a module load — non in una funzione — così esiste per tutta | |
| // la vita dell'app e sopravvive a respawn multipli. | |
| workerManager.onStatusChange((status) => { | |
| if (status.type !== "py") return; | |
| if (status.health === "dead") { | |
| // Worker morto (crash o idle terminate) → pulisci riferimento | |
| _pyWorkerRef = null; | |
| for (const [, cb] of _pyListeners) { | |
| cb({ type: "error", message: "Worker Pyodide terminato — riprova" }); | |
| } | |
| _pyListeners.clear(); | |
| return; | |
| } | |
| if (status.health === "alive") { | |
| // Worker spawned o respawned → riattacca handler se cambiato | |
| try { | |
| const wl = workerManager.ensureSpawned("py"); | |
| if (wl !== _pyWorkerRef) { | |
| _attachPyHandler(wl); | |
| } | |
| } catch { /* non-fatal: _getPyWorker lo ritenterà */ } | |
| } | |
| }); | |
| /** Chiamato da agentLoop.ts per agganciare onStatus al bridge Pyodide. */ | |
| export function setPyodideStatus(fn: ((s: string) => void) | null): void { | |
| _pyStatus = fn; | |
| } | |
| /** | |
| * Ritorna il worker Pyodide attivo, spawnandolo se necessario. | |
| * WorkerManager gestisce health check e respawn: questa funzione si | |
| * limita a ottenere il worker live e garantire che l'handler sia attaccato. | |
| */ | |
| function _getPyWorker(): WorkerLike | null { | |
| if (!WorkerManager.isWorkerSupported()) return null; | |
| try { | |
| const wl = workerManager.ensureSpawned("py"); | |
| // Attacca handler se non ancora fatto (prima use) o dopo respawn via onStatusChange | |
| if (wl !== _pyWorkerRef) { | |
| _attachPyHandler(wl); | |
| } | |
| return _pyWorkerRef; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| // ─── Helper: Pyodide worker bridge (run_python tool) ───────────────────────── | |
| // S139-Fix3 — Safari iOS detection: module workers non supportati in modo affidabile. | |
| // Fallback trasparente su backend HF Space (già configurato come EXEC_BACKEND). | |
| function _isSafariIOS(): boolean { | |
| if (typeof navigator === "undefined") return false; | |
| return /iPad|iPhone|iPod/.test(navigator.userAgent) && | |
| !(window as unknown as Record<string, unknown>).MSStream; | |
| } | |
| /** | |
| * runViaPyodideWorker — entry point pubblico. | |
| * Su Safari iOS: delega l'esecuzione Python al backend HF Space (exec engine). | |
| * Su altri browser: usa il worker Pyodide locale. | |
| */ | |
| function runViaPyodideWorker(code: string, packages: string[] = [], signal?: AbortSignal): Promise<string> { | |
| if (_isSafariIOS()) { | |
| return import("../backendClient").then(({ backend }) => { | |
| if (backend.isExecAvailable()) { | |
| const fullCode = packages.length > 0 | |
| ? `import subprocess\nsubprocess.run(["pip","install","-q",${packages.map(p => JSON.stringify(p)).join(",")}], capture_output=True)\n` + code | |
| : code; | |
| return backend.execCode(fullCode, "python", 30) | |
| .then(r => { | |
| // S768: partial output — emetti evento | |
| // S769: persist al VFS | |
| if (r.partial) { | |
| const _partialSnip = (r.stdout ?? "").slice(0, 300); | |
| try { eventRuntime.emit({ kind: "partial_output", tool: "run_python", partialOut: _partialSnip }); } catch { /* non-blocking */ } | |
| const _ts = Date.now(); | |
| vfsAsync.write( | |
| `.partial_out_${_ts}.txt`, | |
| `[S769] tool: run_python\ntime: ${new Date(_ts).toISOString()}\n\n${r.stdout ?? ""}`, | |
| ).catch(() => {}); | |
| } | |
| const out = [r.stdout, r.stderr ? `[stderr] ${r.stderr}` : ""].filter(Boolean).join("\n").trim(); | |
| return out || "(nessun output)"; | |
| }) | |
| .catch((e: Error) => `❌ Python (server): ${e.message}`); | |
| } | |
| // Backend non disponibile: tenta comunque il worker (Safari 16.4+ lo supporta parzialmente) | |
| return _runViaPyodideWorkerInternal(code, packages, signal); | |
| }); | |
| } | |
| return _runViaPyodideWorkerInternal(code, packages, signal); | |
| } | |
| function _runViaPyodideWorkerInternal(code: string, packages: string[] = [], signal?: AbortSignal): Promise<string> { | |
| return new Promise<string>((resolve) => { | |
| const worker = _getPyWorker(); | |
| if (!worker) { | |
| resolve("❌ Worker Pyodide non disponibile (Safari iOS potrebbe non supportare module workers — prova Chrome/Firefox)"); | |
| return; | |
| } | |
| let stdout = ""; | |
| let stderr = ""; | |
| const id = crypto.randomUUID(); | |
| const cleanup = () => { | |
| clearTimeout(timer); | |
| _pyListeners.delete(id); | |
| signal?.removeEventListener("abort", abortHandler); | |
| }; | |
| // RT-3: abort propagation — risolve immediatamente senza aspettare i 32s di timeout | |
| const abortHandler = () => { | |
| cleanup(); | |
| const partial = [stdout, stderr ? `[stderr] ${stderr}` : ""].filter(Boolean).join("\n"); | |
| resolve(`❌ [ABORT] Esecuzione Python interrotta dall'utente.${partial ? "\nOutput parziale:\n" + partial : ""}`); | |
| }; | |
| if (signal?.aborted) { resolve("❌ [ABORT] Esecuzione Python interrotta dall'utente."); return; } | |
| if (signal) signal.addEventListener("abort", abortHandler, { once: true }); | |
| const timer = setTimeout(() => { | |
| cleanup(); | |
| const partial = [stdout, stderr ? `[stderr] ${stderr}` : ""].filter(Boolean).join("\n"); | |
| resolve(`❌ Python timeout (30s)${partial ? "\nOutput parziale:\n" + partial : ""}`); | |
| }, 32_000); | |
| _pyListeners.set(id, (msg: _PyWorkerMsg) => { | |
| if (msg.type === "stdout") { | |
| const text = msg.text ?? ""; | |
| if (text.startsWith("[pip]")) { | |
| // S70: messaggi install → feedback live via onStatus | |
| const label = text.replace("[pip]", "").replace(/\n$/, "").trim(); | |
| if (label && _pyStatus) { | |
| if (label.startsWith("✓")) _pyStatus(`✅ Python: ${label.slice(1).trim()}`); | |
| else _pyStatus(`📦 ${label}`); | |
| } | |
| stdout += text; | |
| } else { | |
| stdout += text; | |
| } | |
| } else if (msg.type === "stderr") { | |
| stderr += msg.text ?? ""; | |
| } else if (msg.type === "done" || msg.type === "error") { | |
| cleanup(); | |
| const exitCode = msg.type === "done" ? (msg.exitCode ?? 0) : 1; | |
| // Rimuovi linee [pip] dall'output finale — l'utente le ha già viste live | |
| const cleanStdout = stdout | |
| .split("\n") | |
| .filter(l => !l.startsWith("[pip]")) | |
| .join("\n") | |
| .trim(); | |
| const out = [cleanStdout, stderr ? `[stderr] ${stderr.trim()}` : ""] | |
| .filter(Boolean) | |
| .join("\n") || "(nessun output)"; | |
| resolve(exitCode === 0 ? out : `❌ Python error (exit ${exitCode}):\n${out}`); | |
| } | |
| }); | |
| worker.postMessage({ type: "run", id, code, packages }); | |
| }); | |
| } | |