import { useCallback, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import { MAX_RUNS, RunsContext, STORAGE_KEY } from './runs' import type { Run, RunsContextValue } from './runs' /** Sanitize a single persisted run. * * Returns null if the entry is structurally bogus (missing required keys, * wrong types). Without this, an arbitrary localStorage payload could * crash the Processes / Library / Home views by reaching r.tool.split etc. */ function migrateRun(value: unknown): Run | null { if (!value || typeof value !== 'object') return null const r = value as Partial if (typeof r.id !== 'string' || typeof r.tool !== 'string') return null if (!['text-to-video', 'html-to-video', 'image-to-video', 'screenshots-to-video'].includes(r.tool)) return null if (typeof r.startedAt !== 'number') return null if (!['running', 'success', 'error', 'cancelled'].includes(r.status as string)) { r.status = 'error' } // Backend-owned text runs can be reattached from Processes by operation id. // Only local/SSE-only running rows become cancelled after a tab reload. if (r.status === 'running' && !r.operationId) { r.status = 'cancelled' r.endedAt = r.endedAt ?? Date.now() } if (r.screenshotFiles && !Array.isArray(r.screenshotFiles)) { r.screenshotFiles = [] } if (typeof r.inputPreview !== 'string') r.inputPreview = '' if (r.inputText != null && typeof r.inputText !== 'string') r.inputText = String(r.inputText) return r as Run } function load(): Run[] { if (typeof window === 'undefined') return [] try { const raw = window.localStorage.getItem(STORAGE_KEY) if (!raw) return [] const parsed = JSON.parse(raw) if (!Array.isArray(parsed)) return [] const cleaned: Run[] = [] for (const entry of parsed) { const migrated = migrateRun(entry) if (migrated) cleaned.push(migrated) } return cleaned } catch { return [] } } function save(runs: Run[]) { if (typeof window === 'undefined') return try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(runs.slice(0, MAX_RUNS))) } catch { /* quota or private-mode; ignore */ } } export function RunsProvider({ children }: { children: ReactNode }) { const [runs, setRuns] = useState(() => load()) useEffect(() => { save(runs) }, [runs]) const start = useCallback((meta) => { const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` const run: Run = { id, status: 'running', startedAt: Date.now(), ...meta } setRuns((prev) => [run, ...prev].slice(0, MAX_RUNS)) return id }, []) const finish = useCallback((id, patch) => { setRuns((prev) => prev.map((r) => r.id === id ? { ...r, ...patch, status: patch.status ?? r.status, endedAt: Date.now(), } : r, ), ) }, []) const update = useCallback((id, patch) => { setRuns((prev) => prev.map((r) => r.id === id ? { ...r, ...patch, status: patch.status ?? r.status, } : r, ), ) }, []) const clear = useCallback(() => setRuns([]), []) const remove = useCallback((id: string) => setRuns((prev) => prev.filter((r) => r.id !== id)), []) const value = useMemo( () => ({ runs, start, finish, update, clear, remove }), [runs, start, finish, update, clear, remove], ) return {children} }