diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0f544e0f..6aa845cc 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -37,7 +37,7 @@ import type { import type { AppLocale } from "@shared/locale"; import { createTranslator, LocaleContext, TranslationContext } from "./i18n"; import { useAppStore } from "./state/store"; -import { createEventBatcher } from "./lib/ipc-batcher"; +import { createDomainEventBatcher, type GranularEventEntry } from "./lib/ipc-batcher"; import { CompanyOnboarding } from "./components/CompanyOnboarding"; import { Inbox, StandupView, PerformanceView } from "./components/inbox"; import { ConnectorEditor } from "./components/connectors"; @@ -236,17 +236,113 @@ export function App() { refreshRef.current = refresh; }, [refresh]); - // IPC event subscription with batcher for coalescing rapid domain-changed events + // IPC event subscription with enhanced batcher for granular + full-refresh coalescing useEffect(() => { void refreshRef.current(); const store = useAppStore; - const batcher = createEventBatcher(() => { - void refreshRef.current(); - }, 50); + + async function handleGranularBatch(events: GranularEventEntry[]) { + const companyId = store.getState().currentCompanyId; + if (!companyId) { + void refreshRef.current(); + return; + } + + let needsFullRefresh = false; + + for (const entry of events) { + // run-updated:completed touches too many entity types -- full refresh + if (entry.type === "run-updated" && entry.action === "completed") { + needsFullRefresh = true; + break; + } + + try { + switch (entry.type) { + case "task-updated": { + if (entry.action === "deleted") { + store.getState().removeTask(entry.entityId); + } else { + const result = await window.agentCompany.getTask({ companyId, id: entry.entityId }); + if (result.ok) { + store.getState().patchTask(result.data); + } else { + store.getState().removeTask(entry.entityId); + } + } + break; + } + case "run-updated": { + if (entry.action === "deleted") { + store.getState().removeRun(entry.entityId); + } else { + const result = await window.agentCompany.getRun({ companyId, id: entry.entityId }); + if (result.ok) { + store.getState().patchRun(result.data); + } else { + store.getState().removeRun(entry.entityId); + } + } + break; + } + case "approval-updated": { + if (entry.action === "deleted") { + store.getState().removeApproval(entry.entityId); + } else { + const result = await window.agentCompany.getApproval({ companyId, id: entry.entityId }); + if (result.ok) { + store.getState().patchApproval(result.data); + } else { + store.getState().removeApproval(entry.entityId); + } + } + break; + } + case "agent-updated": { + const result = await window.agentCompany.getAgent({ companyId, id: entry.entityId }); + if (result.ok) { + store.getState().patchAgent(result.data); + } else { + needsFullRefresh = true; + } + break; + } + case "budget-changed": { + // Budget is a computed aggregate -- full refresh is needed + needsFullRefresh = true; + break; + } + default: { + // Unknown granular event type -- fall back (per D-05 / PERF-03) + needsFullRefresh = true; + break; + } + } + } catch { + // IPC error during entity fetch -- fall back to full refresh (per pitfall 1) + needsFullRefresh = true; + break; + } + + if (needsFullRefresh) break; + } + + if (needsFullRefresh) { + void refreshRef.current(); + } else { + // Refresh sidebar badges after granular patches (per pitfall 3) + void store.getState().refreshBadges(); + } + } + + const batcher = createDomainEventBatcher( + () => { void refreshRef.current(); }, + (events) => { void handleGranularBatch(events); }, + 50, + ); const unsubscribe = window.agentCompany.subscribe((event: DesktopEvent) => { if (event.type === "run-log") { - // Pass through immediately -- no batching for streaming logs const MAX_LOG_BYTES_PER_RUN = 512_000; const MAX_TRACKED_RUNS = 50; const existing = logBufferRef.current[event.runId] ?? ""; @@ -273,8 +369,10 @@ export function App() { store.getState().navigateTo(event.section, event.entityId); return; } - // All data-mutating events: batch the snapshot reload - batcher.schedule(); + // All data-mutating events go through the enhanced batcher + // domain-changed, task-updated, run-updated, approval-updated, + // agent-updated, budget-changed, and unknown types (trigger full refresh) + batcher.push(event); }); return () => { unsubscribe(); diff --git a/tests/unit/selective-ipc.test.ts b/tests/unit/selective-ipc.test.ts index 2dea90bc..0acbf95f 100644 --- a/tests/unit/selective-ipc.test.ts +++ b/tests/unit/selective-ipc.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import type { DesktopEvent, TaskUpdatedEvent, @@ -7,6 +7,7 @@ import type { AgentUpdatedEvent, BudgetChangedEvent, } from "@shared/types"; +import type { GranularEventEntry } from "@renderer/lib/ipc-batcher"; describe("selective IPC event types", () => { it("TaskUpdatedEvent discriminated by type field", () => { @@ -115,3 +116,258 @@ describe("selective IPC event types", () => { expect(typeof exhaustiveCheck).toBe("function"); }); }); + +// Extracted granular batch handler logic for testability +// Mirrors the handleGranularBatch function in App.tsx +async function handleGranularBatch( + events: GranularEventEntry[], + deps: { + companyId: string | null; + getTask: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: any }>; + getRun: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: any }>; + getApproval: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: any }>; + getAgent: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: any }>; + patchTask: (task: any) => void; + removeTask: (id: string) => void; + patchRun: (run: any) => void; + removeRun: (id: string) => void; + patchApproval: (approval: any) => void; + removeApproval: (id: string) => void; + patchAgent: (agent: any) => void; + refreshBadges: () => Promise; + fullRefresh: () => void; + }, +) { + if (!deps.companyId) { + deps.fullRefresh(); + return; + } + + let needsFullRefresh = false; + + for (const entry of events) { + if (entry.type === "run-updated" && entry.action === "completed") { + needsFullRefresh = true; + break; + } + + try { + switch (entry.type) { + case "task-updated": { + if (entry.action === "deleted") { + deps.removeTask(entry.entityId); + } else { + const result = await deps.getTask({ companyId: deps.companyId, id: entry.entityId }); + if (result.ok) { + deps.patchTask(result.data); + } else { + deps.removeTask(entry.entityId); + } + } + break; + } + case "run-updated": { + if (entry.action === "deleted") { + deps.removeRun(entry.entityId); + } else { + const result = await deps.getRun({ companyId: deps.companyId, id: entry.entityId }); + if (result.ok) { + deps.patchRun(result.data); + } else { + deps.removeRun(entry.entityId); + } + } + break; + } + case "approval-updated": { + if (entry.action === "deleted") { + deps.removeApproval(entry.entityId); + } else { + const result = await deps.getApproval({ companyId: deps.companyId, id: entry.entityId }); + if (result.ok) { + deps.patchApproval(result.data); + } else { + deps.removeApproval(entry.entityId); + } + } + break; + } + case "agent-updated": { + const result = await deps.getAgent({ companyId: deps.companyId, id: entry.entityId }); + if (result.ok) { + deps.patchAgent(result.data); + } else { + needsFullRefresh = true; + } + break; + } + case "budget-changed": { + needsFullRefresh = true; + break; + } + default: { + needsFullRefresh = true; + break; + } + } + } catch { + needsFullRefresh = true; + break; + } + + if (needsFullRefresh) break; + } + + if (needsFullRefresh) { + deps.fullRefresh(); + } else { + void deps.refreshBadges(); + } +} + +function createMockDeps() { + return { + companyId: "company-1" as string | null, + getTask: vi.fn().mockResolvedValue({ ok: true, data: { id: "t-1", title: "Test task" } }), + getRun: vi.fn().mockResolvedValue({ ok: true, data: { id: "r-1", status: "running" } }), + getApproval: vi.fn().mockResolvedValue({ ok: true, data: { id: "a-1", state: "pending" } }), + getAgent: vi.fn().mockResolvedValue({ ok: true, data: { id: "ag-1", name: "Agent" } }), + patchTask: vi.fn(), + removeTask: vi.fn(), + patchRun: vi.fn(), + removeRun: vi.fn(), + patchApproval: vi.fn(), + removeApproval: vi.fn(), + patchAgent: vi.fn(), + refreshBadges: vi.fn().mockResolvedValue(undefined), + fullRefresh: vi.fn(), + }; +} + +describe("handleGranularBatch", () => { + it("task-updated event calls getTask and patchTask", async () => { + const deps = createMockDeps(); + await handleGranularBatch( + [{ type: "task-updated", entityId: "t-1", action: "updated" }], + deps, + ); + expect(deps.getTask).toHaveBeenCalledWith({ companyId: "company-1", id: "t-1" }); + expect(deps.patchTask).toHaveBeenCalledWith({ id: "t-1", title: "Test task" }); + expect(deps.fullRefresh).not.toHaveBeenCalled(); + expect(deps.refreshBadges).toHaveBeenCalled(); + }); + + it("deleted task calls removeTask without fetching", async () => { + const deps = createMockDeps(); + await handleGranularBatch( + [{ type: "task-updated", entityId: "t-1", action: "deleted" }], + deps, + ); + expect(deps.removeTask).toHaveBeenCalledWith("t-1"); + expect(deps.getTask).not.toHaveBeenCalled(); + expect(deps.fullRefresh).not.toHaveBeenCalled(); + }); + + it("run-updated:completed triggers full refresh", async () => { + const deps = createMockDeps(); + await handleGranularBatch( + [{ type: "run-updated", entityId: "r-1", action: "completed" }], + deps, + ); + expect(deps.fullRefresh).toHaveBeenCalled(); + expect(deps.getRun).not.toHaveBeenCalled(); + expect(deps.refreshBadges).not.toHaveBeenCalled(); + }); + + it("failed getTask fetch falls back to removeTask", async () => { + const deps = createMockDeps(); + deps.getTask.mockResolvedValue({ ok: false, error: { code: "NOT_FOUND", message: "nope" } }); + await handleGranularBatch( + [{ type: "task-updated", entityId: "t-1", action: "updated" }], + deps, + ); + expect(deps.removeTask).toHaveBeenCalledWith("t-1"); + expect(deps.patchTask).not.toHaveBeenCalled(); + }); + + it("calls refreshBadges after successful granular processing", async () => { + const deps = createMockDeps(); + await handleGranularBatch( + [{ type: "task-updated", entityId: "t-1", action: "created" }], + deps, + ); + expect(deps.refreshBadges).toHaveBeenCalled(); + expect(deps.fullRefresh).not.toHaveBeenCalled(); + }); + + it("approval-updated event calls getApproval and patchApproval", async () => { + const deps = createMockDeps(); + await handleGranularBatch( + [{ type: "approval-updated", entityId: "a-1", action: "decided" }], + deps, + ); + expect(deps.getApproval).toHaveBeenCalledWith({ companyId: "company-1", id: "a-1" }); + expect(deps.patchApproval).toHaveBeenCalledWith({ id: "a-1", state: "pending" }); + }); + + it("agent-updated event calls getAgent and patchAgent", async () => { + const deps = createMockDeps(); + await handleGranularBatch( + [{ type: "agent-updated", entityId: "ag-1", action: "paused" }], + deps, + ); + expect(deps.getAgent).toHaveBeenCalledWith({ companyId: "company-1", id: "ag-1" }); + expect(deps.patchAgent).toHaveBeenCalledWith({ id: "ag-1", name: "Agent" }); + }); + + it("budget-changed triggers full refresh", async () => { + const deps = createMockDeps(); + await handleGranularBatch( + [{ type: "budget-changed", entityId: "c-1", action: "changed" }], + deps, + ); + expect(deps.fullRefresh).toHaveBeenCalled(); + expect(deps.refreshBadges).not.toHaveBeenCalled(); + }); + + it("no companyId triggers full refresh", async () => { + const deps = createMockDeps(); + deps.companyId = null; + await handleGranularBatch( + [{ type: "task-updated", entityId: "t-1", action: "updated" }], + deps, + ); + expect(deps.fullRefresh).toHaveBeenCalled(); + expect(deps.getTask).not.toHaveBeenCalled(); + }); + + it("IPC error falls back to full refresh", async () => { + const deps = createMockDeps(); + deps.getTask.mockRejectedValue(new Error("IPC failed")); + await handleGranularBatch( + [{ type: "task-updated", entityId: "t-1", action: "updated" }], + deps, + ); + expect(deps.fullRefresh).toHaveBeenCalled(); + }); + + it("unknown event type triggers full refresh", async () => { + const deps = createMockDeps(); + await handleGranularBatch( + [{ type: "some-unknown-event", entityId: "x-1", action: "created" }], + deps, + ); + expect(deps.fullRefresh).toHaveBeenCalled(); + }); + + it("run-updated:status_changed fetches and patches run", async () => { + const deps = createMockDeps(); + await handleGranularBatch( + [{ type: "run-updated", entityId: "r-1", action: "status_changed" }], + deps, + ); + expect(deps.getRun).toHaveBeenCalledWith({ companyId: "company-1", id: "r-1" }); + expect(deps.patchRun).toHaveBeenCalledWith({ id: "r-1", status: "running" }); + expect(deps.fullRefresh).not.toHaveBeenCalled(); + }); +});