233 lines
13 KiB
TypeScript
233 lines
13 KiB
TypeScript
// владелец состояния модулей: один опрос 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<Pick<HubModule, "status">> => !!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<ModuleEvent | 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 | null>(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<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;
|
||
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<ModuleActivity>(() => ({ 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<ActivityValue>(() => ({ agents: agents ?? [], modules, summary }), [agents, modules, summary]);
|
||
return <ActivityContext.Provider value={value}>{children}</ActivityContext.Provider>;
|
||
}
|