sdlc-hub/web/src/modules/jira/refresh/RefreshProvider.tsx
2026-10-24 04:02:31 +04:00

175 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// владелец состояния «Обновить из 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<RefreshEvent | null>(null);
const [active, setActive] = useState<Active | null>(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<AgentView>("/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<Batch>(`/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<Batch>(`/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<AgentView>("/agent");
const b = await api.post<CreatedBatch>("/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<JiraRefresh>(() => ({
event, busy: starting || active !== null, agent: agent.data ?? null, start, track,
}), [event, starting, active, agent.data, start, track]);
return <RefreshContext.Provider value={value}>{children}</RefreshContext.Provider>;
}