// владелец состояния модулей: один опрос GET /agents и один трекер пакетов на всё приложение, не больше одного пакета // на модуль. Правила остановки опроса — прежние (hub#20, перенос RefreshProvider модуля jira): одна кнопка и один опрос // (M-2), остановка опроса (M-4, T10.2). Подхват (M-3) теперь по модулю и без привязки к снимку доски: при монтировании, // смене проекта, раз в 60 с и после 409 на постановке; пакет любого автора (design web-modules, решение 4) import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useProjects } from "../../api/queries.ts"; import { api, ApiError, AuthError, dropTokenOn, HubDownError } from "../../api/client.ts"; import type { AgentView, Batch } from "../../api/types.ts"; import { MODULES } from "../../modules/index.ts"; import type { HubModule } from "../../modules/types.ts"; import { ActivityContext, AGENTS_KEY, type ActivityValue, type ModuleActivity } from "./activity.ts"; import { DONE_VISIBLE_MS, MAX_POLL_ERRORS, PICKUP_LIMIT, PICKUP_MS, POLL_MS, type ModuleEvent } from "./events.ts"; interface Active { id: number; agentOffline: boolean; own: boolean } // own — пакет действия обновления этого модуля const terminal = (b: Batch | undefined) => !!b && (b.status === "done" || b.status === "cancelled"); const fetchAgents = () => api.get<{ agents: AgentView[] }>("/agents").then((r) => r.agents); const isError = (e: ModuleEvent | null) => e?.kind === "batch-failed" || e?.kind === "batch-lost" || e?.kind === "hub-down" || e?.kind === "error"; // модули с описанием состояния; список — константа: хуки трекеров вызываются в постоянном порядке const TRACKED = MODULES.filter((m): m is HubModule & Required> => !!m.status); interface Env { qc: QueryClient; project: string | null; pollMs: number; agents: AgentView[] | undefined } function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents }: Env) { const { name, status } = m; const refresh = status.refresh; const scoped = status.scope === "project"; 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(null); const picking = useRef(false); const generation = useRef(0); // растёт при смене проекта: ответы прежнего проекта отбрасываются const projectRef = useRef(project); const agentsRef = useRef(agents); useEffect(() => { projectRef.current = project; agentsRef.current = agents; }); const agent = status.agent ? agents?.find((a) => a.module === status.agent) ?? null : null; const offlineNow = () => !!status.agent && agentsRef.current?.find((a) => a.module === status.agent)?.online === false; const setActiveNow = useCallback((a: Active | null) => { activeRef.current = a; setActive(a); }, []); 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: подхватить незавершённый пакет модуля (самый старый), если ничего не отслеживается // replaceForeign — после 409: идущий пакет важнее чужого отслеживаемого (T3.I1), чужой пропускается при выборе const pickup = useCallback((replaceForeign = false) => { if (!refresh) return; if (scoped && projectRef.current === null) return; const blocked = () => !!activeRef.current && !(replaceForeign && !activeRef.current.own); if (blocked() || startingRef.current || picking.current) return; picking.current = true; const gen = generation.current; (async () => { const r = await api.get<{ batches: Batch[] }>("/batches", { module: name, limit: PICKUP_LIMIT, project: scoped ? projectRef.current : undefined }); if (gen !== generation.current || blocked() || startingRef.current) return; const skip = replaceForeign ? activeRef.current?.id : undefined; const open = r.batches.filter((b) => (b.status === "queued" || b.status === "running") && b.id !== skip).sort((a, b) => a.id - b.id)[0]; if (!open) return; failures.current = 0; setActiveNow({ id: open.id, agentOffline: offlineNow(), own: false }); })().catch(() => { /* не критично: подхватим при следующем проходе */ }).finally(() => { if (gen === generation.current) picking.current = false; }); // eslint-disable-next-line react-hooks/exhaustive-deps -- offlineNow читает ref, имя и охват модуля постоянны }, [refresh, scoped, name, setActiveNow]); // смена проекта: отслеживаемый пакет проектного модуля сбрасывается без события; подхват — по всем модулям const prevProject = useRef(project); useEffect(() => { if (prevProject.current !== project) { prevProject.current = project; if (scoped) { generation.current += 1; picking.current = false; failures.current = 0; setActiveNow(null); setEvent(null); } } pickup(); const t = setInterval(() => pickup(), PICKUP_MS); return () => clearInterval(t); }, [project, scoped, pickup, setActiveNow]); // 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; setActiveNow(null); qc.removeQueries({ queryKey: ["batch", id] }); status.invalidate(qc, projectRef.current); void qc.invalidateQueries({ queryKey: ["projects"] }); }, [qc, status, setActiveNow]); 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; } if (!active.own && refresh?.isOwn(b)) { setActiveNow({ ...active, own: true }); 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, refresh, setActiveNow]); // «пакет выполнен» гаснет сам 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 () => { const p = projectRef.current; if (!refresh || (scoped && p === null)) return; if (startingRef.current || activeRef.current?.own) return; startingRef.current = true; setStarting(true); let conflict = false; try { const list = await qc.fetchQuery({ queryKey: AGENTS_KEY, queryFn: fetchAgents, staleTime: 0 }); const offline = !!status.agent && list.find((a) => a.module === status.agent)?.online === false; const gen = generation.current; let b: { id: number; estimate_seconds?: number }; try { b = await refresh.start(p ?? ""); } catch (e) { if (e instanceof ApiError && e.status === 409) { conflict = true; return; } // уже идёт: подхватим без события ошибки throw e; } if (gen !== generation.current) return; // проект сменился во время постановки: пакет не этого проекта failures.current = 0; setActiveNow({ id: b.id, agentOffline: offline, own: true }); setEvent({ kind: "batch", id: b.id, phase: "queued", remaining: b.estimate_seconds ?? 0, agentOffline: offline, retrying: false }); } catch (e) { report(e); } finally { startingRef.current = false; setStarting(false); if (conflict) pickup(true); } }, [refresh, scoped, status, qc, report, pickup, setActiveNow]); const track = useCallback((id: number) => { if (activeRef.current?.own) return; failures.current = 0; setActiveNow({ id, agentOffline: false, own: true }); setEvent({ kind: "batch", id, phase: "queued", remaining: 0, agentOffline: false, retrying: false }); }, [setActiveNow]); const running = active && batch.data?.id === active.id ? batch.data : null; const busy = starting || !!active?.own; const view = useMemo(() => ({ agent, event, busy, running, start, track }), [agent, event, busy, running, start, track]); return { view, tracking: starting || active !== null }; } // хук-трекер для модуля: имя «use…» позволяет вызывать его в постоянном порядке по константному списку const TRACKER_HOOKS = TRACKED.map((m) => function useModuleTracker(env: Env) { return useTracker(m, env); }); export function ActivityProvider({ children, project, pollMs = POLL_MS }: { children: ReactNode; project: string | null; pollMs?: number }) { const qc = useQueryClient(); const agentsQ = useQuery({ queryKey: AGENTS_KEY, queryFn: fetchAgents, refetchInterval: PICKUP_MS, refetchIntervalInBackground: true }); const agents = agentsQ.data; const env: Env = { qc, project, pollMs, agents }; const trackers = TRACKER_HOOKS.map((use) => use(env)); // постоянный порядок: список — константа const modules = useMemo(() => Object.fromEntries(TRACKED.map((m, i) => [m.name, trackers[i].view])), // eslint-disable-next-line react-hooks/exhaustive-deps -- представления модулей мемоизированы в трекерах trackers.map((t) => t.view)); // сводная точка (spec «Оболочка и адреса»): ошибка у любого модуля → пакет в работе → агенты на связи или нет. // Офлайн-агент модуля без данных у проекта (у вики без корня) сводку не красит: ему нечего обновлять const projectData = useProjects().data?.find((x) => x.label === project); const agentMatters = (m: (typeof TRACKED)[number]) => m.status.scope === "all" || !projectData || m.status.freshness(projectData).available; const summary: ActivityValue["summary"] = TRACKED.some((m) => isError(modules[m.name].event)) ? "error" : trackers.some((t) => t.tracking) ? "busy" : TRACKED.some((m) => !!m.status.agent && modules[m.name].agent?.online === false && agentMatters(m)) ? "offline" : "online"; const value = useMemo(() => ({ agents: agents ?? [], modules, summary }), [agents, modules, summary]); return {children}; }