YT-AI-Automation / frontend /src /store /RunsProvider.tsx
github-actions
Sync Docker Space
5f3e9f5
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<Run>
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<Run[]>(() => load())
useEffect(() => {
save(runs)
}, [runs])
const start = useCallback<RunsContextValue['start']>((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<RunsContextValue['finish']>((id, patch) => {
setRuns((prev) =>
prev.map((r) =>
r.id === id
? {
...r,
...patch,
status: patch.status ?? r.status,
endedAt: Date.now(),
}
: r,
),
)
}, [])
const update = useCallback<RunsContextValue['update']>((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<RunsContextValue>(
() => ({ runs, start, finish, update, clear, remove }),
[runs, start, finish, update, clear, remove],
)
return <RunsContext.Provider value={value}>{children}</RunsContext.Provider>
}