sdlc-hub/web/src/shell/activity/ActivityProvider.tsx

233 lines
13 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.

// владелец состояния модулей: один опрос 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>;
}