From 2bc43953dc847c8fb59c4e1b13b19470612a1ab8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 15:18:37 +0400 Subject: [PATCH] =?UTF-8?q?fix(web-modules):=20M-1,=20M-2,=20M-7,=20M-8=20?= =?UTF-8?q?=D1=82=D1=80=D0=B5=D0=BA=D0=B5=D1=80=20=D0=BF=D0=B0=D0=BA=D0=B5?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20=D0=B8=20=D1=81=D0=B2=D0=BE=D0=B4=D0=BA?= =?UTF-8?q?=D0=B0=20jira?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- web/src/modules/jira/Summary.tsx | 9 ++---- web/src/shell/NewProject.test.tsx | 4 +-- web/src/shell/NewProject.tsx | 2 +- web/src/shell/activity/ActivityProvider.tsx | 23 +++++++++------ web/src/shell/activity/activity.test.tsx | 31 +++++++++++++++++++++ web/src/shell/activity/activity.ts | 6 ++-- 6 files changed, 54 insertions(+), 21 deletions(-) diff --git a/web/src/modules/jira/Summary.tsx b/web/src/modules/jira/Summary.tsx index 477bdf0..18a249a 100644 --- a/web/src/modules/jira/Summary.tsx +++ b/web/src/modules/jira/Summary.tsx @@ -3,21 +3,18 @@ import type { Project } from "../../api/types.ts"; import { Notice } from "../../ui/index.ts"; import { useBoard } from "./api/queries.ts"; import { isClosed, useClosedStatuses } from "./api/closed.ts"; +import { isOverdue, todayISO } from "./issues/card.ts"; import "./summary.css"; -// сегодня по местному времени, ГГГГ-ММ-ДД: срок задачи Jira — дата без времени -const today = (now = new Date()): string => - `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; - // сводка jira для «Обзора»: свои задачи проекта (не соседи), из них открытые и просроченные — по снимку доски export function Summary({ project }: { project: Project }) { const board = useBoard(project.label); const closed = useClosedStatuses(); const q = `?project=${encodeURIComponent(project.label)}`; const own = board.data?.issues.filter((i) => !i.neighbor); + const t = todayISO(); const open = own?.filter((i) => !isClosed(i.status, closed)); - const t = today(); - const late = open?.filter((i) => !!i.duedate && i.duedate.slice(0, 10) < t).length ?? 0; + const late = own?.filter((i) => isOverdue(i.duedate?.slice(0, 10) ?? "", i.status, closed, t)).length ?? 0; return (
{board.isError ? {board.error.message} diff --git a/web/src/shell/NewProject.test.tsx b/web/src/shell/NewProject.test.tsx index 792d032..ce5caac 100644 --- a/web/src/shell/NewProject.test.tsx +++ b/web/src/shell/NewProject.test.tsx @@ -25,7 +25,7 @@ describe("NewProject: пакеты каждому своему модулю", () await userEvent.type(screen.getByLabelText("Название"), "Пэ"); await userEvent.click(screen.getByRole("button", { name: "Создать" })); await waitFor(() => expect(select).toHaveBeenCalledWith("P")); - expect(modules.jira.track).toHaveBeenCalledExactlyOnceWith(3); - expect(modules.wiki.track).toHaveBeenCalledExactlyOnceWith(4); + expect(modules.jira.track).toHaveBeenCalledExactlyOnceWith(3, "P"); + expect(modules.wiki.track).toHaveBeenCalledExactlyOnceWith(4, "P"); }); }); diff --git a/web/src/shell/NewProject.tsx b/web/src/shell/NewProject.tsx index daeee69..940fb90 100644 --- a/web/src/shell/NewProject.tsx +++ b/web/src/shell/NewProject.tsx @@ -6,7 +6,7 @@ export function NewProject({ onClose, select }: { onClose(): void; select(label: const track = useTrackModuleBatch(); return { onClose(); - for (const b of batches) track(b.module, b.id); + for (const b of batches) track(b.module, b.id, p.label); // пакет принадлежит новому проекту: смена проекта его не сбрасывает select(p.label); }} />; } diff --git a/web/src/shell/activity/ActivityProvider.tsx b/web/src/shell/activity/ActivityProvider.tsx index 7e13e46..bf64571 100644 --- a/web/src/shell/activity/ActivityProvider.tsx +++ b/web/src/shell/activity/ActivityProvider.tsx @@ -12,7 +12,7 @@ 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 — пакет действия обновления этого модуля +interface Active { id: number; agentOffline: boolean; own: boolean; project: string | null } // own — пакет действия обновления этого модуля; project — проект, которому принадлежит пакет const terminal = (b: Batch | undefined) => !!b && (b.status === "done" || b.status === "cancelled"); const fetchAgents = () => api.get<{ agents: AgentView[] }>("/agents").then((r) => r.agents); @@ -65,7 +65,7 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents } 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 }); + setActiveNow({ id: open.id, agentOffline: offlineNow(), own: false, project: projectRef.current }); })().catch(() => { /* не критично: подхватим при следующем проходе */ }).finally(() => { if (gen === generation.current) picking.current = false; }); // eslint-disable-next-line react-hooks/exhaustive-deps -- offlineNow читает ref, имя и охват модуля постоянны }, [refresh, scoped, name, setActiveNow]); @@ -75,7 +75,8 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents } useEffect(() => { if (prevProject.current !== project) { prevProject.current = project; - if (scoped) { + // пакет проекта, на который перешли (создание проекта: track до смены адреса), не сбрасывается — только чужой (M-2) + if (scoped && activeRef.current?.project !== project) { generation.current += 1; picking.current = false; failures.current = 0; @@ -161,6 +162,7 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents } const start = useCallback(async () => { const p = projectRef.current; + const gen = generation.current; // вместе с проектом, до любого await: пакет старого проекта в строке нового не отслеживается (M-1) if (!refresh || (scoped && p === null)) return; if (startingRef.current || activeRef.current?.own) return; startingRef.current = true; @@ -169,7 +171,6 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents } 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 ?? ""); @@ -179,7 +180,7 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents } } if (gen !== generation.current) return; // проект сменился во время постановки: пакет не этого проекта failures.current = 0; - setActiveNow({ id: b.id, agentOffline: offline, own: true }); + setActiveNow({ id: b.id, agentOffline: offline, own: true, project: p }); setEvent({ kind: "batch", id: b.id, phase: "queued", remaining: b.estimate_seconds ?? 0, agentOffline: offline, retrying: false }); } catch (e) { report(e); @@ -190,17 +191,21 @@ function useTracker(m: (typeof TRACKED)[number], { qc, project, pollMs, agents } } }, [refresh, scoped, status, qc, report, pickup, setActiveNow]); - const track = useCallback((id: number) => { + const track = useCallback((id: number, forProject?: string) => { if (activeRef.current?.own) return; failures.current = 0; - setActiveNow({ id, agentOffline: false, own: true }); + setActiveNow({ id, agentOffline: false, own: true, project: forProject ?? projectRef.current }); 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]); + // M-7: «агент офлайн» подхваченного пакета — по текущему состоянию агента, а не по снимку в момент подхвата + const shown = event?.kind === "batch" && !event.agentOffline && agent?.online === false && active?.id === event.id + ? { ...event, agentOffline: true } : event; + const view = useMemo(() => ({ agent, event: shown, busy, running, start, track }), + // eslint-disable-next-line react-hooks/exhaustive-deps -- shown зависит от event, agent и active + [agent, event, agent?.online, active?.id, busy, running, start, track]); return { view, tracking: starting || active !== null }; } diff --git a/web/src/shell/activity/activity.test.tsx b/web/src/shell/activity/activity.test.tsx index 89598ab..8e0e12a 100644 --- a/web/src/shell/activity/activity.test.tsx +++ b/web/src/shell/activity/activity.test.tsx @@ -337,6 +337,37 @@ describe("ActivityProvider", () => { expect(m.now().jira.event).toMatchObject({ kind: "batch", id: 5 }); }); + it("M-2: пакет, отданный track для нового проекта, переживает смену проекта на него; чужой — сбрасывается", async () => { + hub({ ...byId({ 9: "wiki_sync" }, () => "running") }); + const m = mount(); + await agentsReady(m); + act(() => m.now().wiki.track(9, "NEW")); + m.setProject("NEW"); + await sleep(40); + expect(m.now().wiki.event).toMatchObject({ kind: "batch", id: 9 }); + m.setProject("OTHER"); + await waitFor(() => expect(m.now().wiki.event).toBeNull()); + }); + + it("M-1: проект сменился во время запроса агентов — пакет старого проекта в строке нового не отслеживается", async () => { + const agents = deferred(); + let first = true; + hub({ + "GET /agents": async () => { if (!first) await agents.promise; first = false; return json(200, { agents: [agentView("jira"), agentView("wiki")] }); }, + "POST /wiki/sync": () => json(201, { batch_id: 11 }), + ...byId({ 11: "wiki_sync" }, () => "running"), + }); + const m = mount(); + await agentsReady(m); + let started!: Promise; + act(() => { started = m.now().wiki.start(); }); + m.setProject("STOCKS_DRRK"); + agents.resolve(); + await act(() => started); + expect(m.now().wiki.event).toBeNull(); + expect(m.now().wiki.busy).toBe(false); + }); + it("wiki 409 tracks running batch: постановка получила 409 — подхвачен идущий пакет, события ошибки нет", async () => { let conflict = false; const h = hub({ diff --git a/web/src/shell/activity/activity.ts b/web/src/shell/activity/activity.ts index acfe89f..da4199e 100644 --- a/web/src/shell/activity/activity.ts +++ b/web/src/shell/activity/activity.ts @@ -9,7 +9,7 @@ export interface ModuleActivity { busy: boolean; // запрос постановки в пути или отслеживаемый пакет — пакет действия обновления модуля running: Batch | null; // отслеживаемый пакет (последний ответ опроса) start(): Promise; // «обновить» текущий проект модуля; без проекта у модуля с охватом «проект» — ничего - track(batchId: number): void; // опрашивать уже поставленный пакет (после создания проекта) + track(batchId: number, project?: string): void; // опрашивать уже поставленный пакет (после создания проекта); project — его проект, по умолчанию текущий } export interface ActivityValue { @@ -33,9 +33,9 @@ export function useModuleActivity(name: string): ModuleActivity { return m; } // отслеживать пакет, поставленный хабом конкретному модулю (после создания/правки проекта); неизвестный модуль пропускается -export function useTrackModuleBatch(): (module: string, batchId: number) => void { +export function useTrackModuleBatch(): (module: string, batchId: number, project?: string) => void { const { modules } = useActivity(); - return useCallback((module, id) => { modules[module]?.track(id); }, [modules]); + return useCallback((module, id, project) => { if (project === undefined) modules[module]?.track(id); else modules[module]?.track(id, project); }, [modules]); } export const useAgents = (): AgentView[] => useActivity().agents; export const useActivitySummary = (): ActivityValue["summary"] => useActivity().summary;