// владелец состояния «Обновить из Jira»: один опрос пакета на всё приложение. Правила — прежние (jira-hub#20): // одна кнопка и один опрос (M-2), подхват незавершённого пакета после любой загрузки снимка (M-3), остановка опроса // (M-4, T10.2), только пакеты с первой командой refresh (T10.6) import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { api, ApiError, AuthError, dropTokenOn, HubDownError } from "../../../api/client.ts"; import { qk } from "../api/queries.ts"; import type { AgentView, Batch, CreatedBatch } from "../../../api/types.ts"; import { RefreshContext, type JiraRefresh } from "./refresh.ts"; import { DONE_VISIBLE_MS, MAX_POLL_ERRORS, POLL_MS, type RefreshEvent } from "./refreshStatus.ts"; interface Active { id: number; agentOffline: boolean } const terminal = (b: Batch | undefined) => !!b && (b.status === "done" || b.status === "cancelled"); export function RefreshProvider({ children, pollMs = POLL_MS }: { children: ReactNode; pollMs?: number }) { const qc = useQueryClient(); const [event, setEvent] = useState(null); const [active, setActive] = useState(null); const [starting, setStarting] = useState(false); const startingRef = useRef(false); const failures = useRef(0); const activeRef = useRef(active); useEffect(() => { activeRef.current = active; }, [active]); // T10.1: сколько ближайших загрузок снимка — перечитывание после своего пакета (пакет уже завершён, подхватывать нечего) const skipPickup = useRef(0); const picking = useRef(false); const agent = useQuery({ queryKey: ["agent"], refetchInterval: 60_000, refetchIntervalInBackground: true, queryFn: () => api.get("/agent"), }); const report = useCallback((e: unknown) => { if (e instanceof AuthError) dropTokenOn(e); else if (e instanceof HubDownError) setEvent({ kind: "hub-down" }); else setEvent({ kind: "error", message: e instanceof Error ? e.message : String(e) }); }, []); // M-3 #20: подхватить незавершённый пакет обновления от viewer const pickup = useCallback(() => { if (activeRef.current || startingRef.current || picking.current) return; picking.current = true; (async () => { const r = await api.get<{ batches: Batch[] }>("/batches", { limit: 20 }); const candidates = r.batches.filter((x) => x.author === "viewer" && (x.status === "queued" || x.status === "running")); for (const b of candidates) { // M-12: пакет wiki_sync не заслоняет более старый refresh const full = await api.get(`/batches/${b.id}`); // T10.6: в списке нет команд — тип смотрим в пакете (R-2) if (activeRef.current || startingRef.current) return; if (full.commands?.[0]?.type !== "refresh") continue; failures.current = 0; setActive((cur) => cur ?? { id: b.id, agentOffline: false }); return; } })().catch(() => { /* не критично: подхватим при следующей загрузке */ }).finally(() => { picking.current = false; }); }, []); useEffect(() => { pickup(); return qc.getQueryCache().subscribe((ev) => { if (ev.type !== "updated" || ev.action.type !== "success" || ev.query.queryKey[0] !== "board") return; if (skipPickup.current > 0) { skipPickup.current -= 1; return; } pickup(); }); }, [qc, pickup]); // M-2, M-4: один опрос пакета; остановка на завершении, любой 4xx (T10.2), 401 и после MAX_POLL_ERRORS ошибок подряд const batch = useQuery({ queryKey: ["batch", active?.id ?? 0], enabled: active !== null, gcTime: 0, staleTime: 0, queryFn: async () => { try { const b = await api.get(`/batches/${active!.id}`); failures.current = 0; return b; } catch (e) { if (e instanceof HubDownError) failures.current += 1; throw e; } }, refetchInterval: (q) => { const e = q.state.error; if (terminal(q.state.data)) return false; if (e instanceof AuthError || e instanceof ApiError) return false; // любая 4xx не лечится повтором if (failures.current >= MAX_POLL_ERRORS) return false; return pollMs; }, refetchIntervalInBackground: true, }); const finish = useCallback((id: number) => { failures.current = 0; setActive(null); qc.removeQueries({ queryKey: ["batch", id] }); // перечитывание активных снимков досок — после своего пакета, подхват не нужен; показанный файл не заменяется (M-1) skipPickup.current = qc.getQueryCache().findAll({ queryKey: qk.boardAll, type: "active" }).length; // T4.1: перечитывание могло упасть (успеха нет, счётчик не уменьшился) — по его завершению счётчик гасится в любом случае void qc.invalidateQueries({ queryKey: qk.boardAll }).finally(() => { skipPickup.current = 0; }); for (const key of [["issues"], ["issue"], ["changes"], qk.projects]) void qc.invalidateQueries({ queryKey: key }); }, [qc]); useEffect(() => { if (!active) return; const b = batch.data, e = batch.error; if (b && b.id === active.id && !batch.isError) { if (terminal(b)) { const cmd = b.commands?.[0]; setEvent(cmd?.status === "failed" ? { kind: "batch-failed", id: b.id, error: cmd.error || "ошибка агента" } : b.status === "cancelled" || cmd?.status === "cancelled" || cmd?.status === "skipped" ? { kind: "batch-cancelled", id: b.id } : { kind: "batch-done", id: b.id }); finish(b.id); return; } setEvent({ kind: "batch", id: b.id, phase: b.status === "queued" ? "queued" : "running", remaining: b.remaining_seconds, agentOffline: active.agentOffline, retrying: false }); return; } if (e instanceof ApiError) { setEvent({ kind: "batch-lost", id: active.id, reason: e.status === 404 ? "пакета нет в хабе" : e.detail ?? e.message }); finish(active.id); } else if (e instanceof HubDownError) { if (failures.current >= MAX_POLL_ERRORS) { setEvent({ kind: "batch-lost", id: active.id, reason: "хаб не отвечает" }); finish(active.id); } else { setEvent((cur) => (cur?.kind === "batch" ? { ...cur, retrying: true } : cur)); } } }, [batch.data, batch.error, batch.isError, batch.errorUpdatedAt, batch.dataUpdatedAt, active, finish]); // «пакет выполнен» гаснет сам useEffect(() => { if (event?.kind !== "batch-done") return; const t = setTimeout(() => setEvent((cur) => (cur === event ? null : cur)), DONE_VISIBLE_MS); return () => clearTimeout(t); }, [event]); const start = useCallback(async () => { if (startingRef.current || activeRef.current) return; startingRef.current = true; setStarting(true); try { const a = await api.get("/agent"); const b = await api.post("/refresh", { full: true, author: "viewer" }); failures.current = 0; const next = { id: b.id, agentOffline: !a.online }; activeRef.current = next; setActive(next); setEvent({ kind: "batch", id: b.id, phase: "queued", remaining: b.estimate_seconds, agentOffline: !a.online, retrying: false }); } catch (e) { report(e); } finally { startingRef.current = false; setStarting(false); } }, [report]); const track = useCallback((id: number) => { if (activeRef.current) return; failures.current = 0; const next = { id, agentOffline: false }; activeRef.current = next; setActive(next); setEvent({ kind: "batch", id, phase: "queued", remaining: 0, agentOffline: false, retrying: false }); }, []); const value = useMemo(() => ({ event, busy: starting || active !== null, agent: agent.data ?? null, start, track, }), [event, starting, active, agent.data, start, track]); return {children}; }