import { useEffect, useMemo, useRef, useState } from 'react'; import type { JobConfig, JobResult } from '../types'; import type { PipelineResponseDTO } from '../services/api'; import { MLBiasAPI } from '../services/api'; type HealthLike = { job_id?: string; timestamp?: string; updated_at?: string; dataset_loaded?: boolean; loaded_models?: string[]; generation_results_available?: boolean; finetune_running?: boolean; steps?: Record; completed?: boolean; status?: string; }; type UseJobRunnerReturn = { result: JobResult | null; resp: PipelineResponseDTO | undefined; loading: boolean; error?: string; start: (config: JobConfig) => Promise; cancel: () => void; jobId: string | null; live: { health: HealthLike | null; steps: Record; updatedAt: string | null; finetuneRunning: boolean; progressPercent: number; }; url: typeof MLBiasAPI.resolvePath; }; export function useJobRunner(): UseJobRunnerReturn { const [jobId, setJobId] = useState(null); const [result, setResult] = useState(null); const [resp, setResp] = useState(); const [loading, setLoading] = useState(false); const [error, setErr] = useState(); const [health, setHealth] = useState(null); const pollRef = useRef(null); const aliveRef = useRef(false); const stopPolling = () => { if (pollRef.current) { window.clearInterval(pollRef.current); pollRef.current = null; } aliveRef.current = false; }; const cancel = () => { stopPolling(); setLoading(false); }; const progressPercent = useMemo(() => { const s = (health?.steps as Record) || {}; const keys = Object.keys(s); if (keys.length === 0) return result?.progress ?? 0; let score = 0; keys.forEach((k) => { const v = s[k]; if (v === true || v === 'done') score += 1; else if (v === 'doing') score += 0.5; }); return Math.max(0, Math.min(100, Math.round((score / keys.length) * 100))); }, [health?.steps, result?.progress]); const liveSteps: Record = useMemo(() => { const fromResp = ((resp?.results as any)?.steps || {}) as Record; const fromHealth = ((health?.steps || {}) as Record); const normalized: Record = {}; Object.keys(fromResp).forEach((k) => (normalized[k] = !!(fromResp as any)[k])); Object.keys(fromHealth).forEach((k) => { const v = (fromHealth as any)[k]; normalized[k] = v === true || v === 'done' || v === 'doing'; }); return normalized; }, [health?.steps, resp?.results]); const pollOnce = async () => { try { const h = (await MLBiasAPI.checkHealth()) as HealthLike; setHealth((prev) => (JSON.stringify(prev) === JSON.stringify(h) ? prev : h)); const steps = (h?.steps || {}) as Record; const plotsDone = !!steps['6_plots_and_metrics'] || (resp?.results as any)?.plots_ready || ((resp?.results as any)?.plot_urls?.length ?? 0) > 0; const r4 = !!steps['4_rank_sampling_original']; const r5 = !!steps['5_rank_sampling_cf']; const samplingDone = r4 && r5; const genAvailable = !!h?.generation_results_available; const ftMaybeDone = !!steps['7_finetune'] || (resp?.results as any)?.finetune_done || (resp?.results as any)?.finetune?.completed; const declaredCompleted = h?.completed === true || h?.status === 'completed'; if (declaredCompleted || plotsDone || samplingDone || (genAvailable && ftMaybeDone)) { stopPolling(); setLoading(false); } } catch (e: any) { setErr((e && e.message) || String(e)); } }; const start = async (config: JobConfig) => { setLoading(true); setErr(undefined); const now = new Date().toISOString(); const provisionalId = crypto.randomUUID(); setResult({ id: provisionalId, status: 'running', progress: 0, config, createdAt: now, updatedAt: now, }); setResp(undefined); setHealth(null); try { const runResp: any = await MLBiasAPI.runPipeline(config); const jid: string | undefined = runResp?.jobId || runResp?.job_id || runResp?.results?.jobId || runResp?.results?.job_id; setJobId(jid || provisionalId); if (runResp?.results?.metrics) { const final = runResp as PipelineResponseDTO; const now2 = new Date().toISOString(); setResp(final); setResult({ id: jid || provisionalId, status: 'completed', progress: 100, config, createdAt: now, updatedAt: now2, completedAt: now2, metrics: { finalMeanDiff: final.results.metrics.finalMeanDiff, reductionPct: final.results.metrics.reductionPct ?? 0, stableCoverage: final.results.metrics.stableCoverage ?? 100, }, }); setLoading(false); return; } aliveRef.current = true; await pollOnce(); if (aliveRef.current) { pollRef.current = window.setInterval(pollOnce, 1000); } } catch (e: any) { setErr(e?.message || String(e)); setResult((prev) => prev ? { ...prev, status: 'failed', progress: 100, updatedAt: new Date().toISOString() } : null ); setLoading(false); } }; useEffect(() => stopPolling, []); const url = MLBiasAPI.resolvePath; return { result, resp, loading, error, start, cancel, jobId, live: { health, steps: liveSteps, updatedAt: (health && (health.updated_at || health.timestamp)) || null, finetuneRunning: !!(health?.finetune_running || (resp as any)?.results?.finetune?.running), progressPercent, }, url, }; }