AUDIT / src /lib /agentLoop /codeRunner.ts
Arypulka98's picture
feat(audit): deploy full backend cluster node (part 2)
cc11e77 verified
Raw
History Blame Contribute Delete
30.4 kB
/**
* 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 });
});
}