From f172a3d9b32438de7691589d95e4bd51f437c7d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 27 Mar 2026 19:35:05 -0400 Subject: [PATCH] feat(09-01): build enhanced event batcher with granular event coalescing - Add createDomainEventBatcher with entity type+ID dedup within 50ms window - Domain-changed overrides all granular events in same batch - More than 10 unique entities triggers full refresh fallback - Delete action preserved during coalescing (last-write-wins otherwise) - Unknown event types trigger full refresh as safety fallback - Legacy createEventBatcher preserved for backward compatibility - Add 12 comprehensive tests for all coalescing behaviors Co-Authored-By: Claude Opus 4.6 (1M context) --- src/renderer/lib/ipc-batcher.ts | 95 +++++++++++++++++ tests/unit/ipc-batcher.test.ts | 181 +++++++++++++++++++++++++++++++- 2 files changed, 275 insertions(+), 1 deletion(-) diff --git a/src/renderer/lib/ipc-batcher.ts b/src/renderer/lib/ipc-batcher.ts index b7055411..f160c30f 100644 --- a/src/renderer/lib/ipc-batcher.ts +++ b/src/renderer/lib/ipc-batcher.ts @@ -1,3 +1,7 @@ +import type { DesktopEvent } from "@shared/types"; + +// --- Legacy simple batcher -- kept for backward compatibility --- + type BatchCallback = () => void; export function createEventBatcher(callback: BatchCallback, windowMs = 50) { @@ -28,3 +32,94 @@ export function createEventBatcher(callback: BatchCallback, windowMs = 50) { return { schedule, flush, dispose }; } + +// --- Enhanced batcher for granular events --- + +export interface GranularEventEntry { + type: string; + entityId: string; + action: string; +} + +const MAX_GRANULAR_EVENTS = 10; + +export function entityKeyFromEvent(event: DesktopEvent): GranularEventEntry | null { + switch (event.type) { + case "task-updated": + return { type: "task-updated", entityId: event.taskId, action: event.action }; + case "run-updated": + return { type: "run-updated", entityId: event.runId, action: event.action }; + case "approval-updated": + return { type: "approval-updated", entityId: event.approvalId, action: event.action }; + case "agent-updated": + return { type: "agent-updated", entityId: event.agentId, action: event.action }; + case "budget-changed": + return { type: "budget-changed", entityId: event.companyId, action: "changed" }; + default: + return null; + } +} + +export function createDomainEventBatcher( + onFullRefresh: () => void, + onGranularBatch: (events: GranularEventEntry[]) => void, + windowMs = 50, + maxGranular = MAX_GRANULAR_EVENTS, +) { + let timer: ReturnType | null = null; + let hasDomainChanged = false; + const pending = new Map(); + + function push(event: DesktopEvent) { + if (event.type === "domain-changed") { + hasDomainChanged = true; + } else { + const entry = entityKeyFromEvent(event); + if (entry) { + const key = `${entry.type}:${entry.entityId}`; + const existing = pending.get(key); + // If any event in window has action "deleted", preserve that action + if (existing && existing.action === "deleted" && entry.action !== "deleted") { + // Keep the delete -- it takes priority + } else { + pending.set(key, entry); + } + } else { + // Unknown event type -- treat as domain-changed for safety (per D-05) + hasDomainChanged = true; + } + } + if (timer === null) { + timer = setTimeout(flushInternal, windowMs); + } + } + + function flushInternal() { + timer = null; + if (hasDomainChanged || pending.size > maxGranular) { + onFullRefresh(); + } else if (pending.size > 0) { + onGranularBatch([...pending.values()]); + } + hasDomainChanged = false; + pending.clear(); + } + + function flush() { + if (timer !== null) { + clearTimeout(timer); + flushInternal(); + } + } + + function dispose() { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + hasDomainChanged = false; + pending.clear(); + } + + return { push, flush, dispose }; +} diff --git a/tests/unit/ipc-batcher.test.ts b/tests/unit/ipc-batcher.test.ts index 100e33c4..a0978ccb 100644 --- a/tests/unit/ipc-batcher.test.ts +++ b/tests/unit/ipc-batcher.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { createEventBatcher } from "@renderer/lib/ipc-batcher"; +import { createEventBatcher, createDomainEventBatcher, type GranularEventEntry } from "@renderer/lib/ipc-batcher"; +import type { DesktopEvent } from "@shared/types"; describe("createEventBatcher", () => { beforeEach(() => { @@ -71,3 +72,181 @@ describe("createEventBatcher", () => { expect(callback).toHaveBeenCalledTimes(2); }); }); + +describe("createDomainEventBatcher", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("domain-changed event triggers full refresh callback after 50ms window", () => { + const onFull = vi.fn(); + const onGranular = vi.fn(); + const batcher = createDomainEventBatcher(onFull, onGranular, 50); + + const domainEvent: DesktopEvent = { type: "domain-changed" }; + batcher.push(domainEvent); + + expect(onFull).not.toHaveBeenCalled(); + vi.advanceTimersByTime(50); + expect(onFull).toHaveBeenCalledTimes(1); + expect(onGranular).not.toHaveBeenCalled(); + }); + + it("multiple domain-changed events in same window coalesce to single full refresh", () => { + const onFull = vi.fn(); + const onGranular = vi.fn(); + const batcher = createDomainEventBatcher(onFull, onGranular, 50); + + batcher.push({ type: "domain-changed" } as DesktopEvent); + batcher.push({ type: "domain-changed" } as DesktopEvent); + batcher.push({ type: "domain-changed" } as DesktopEvent); + + vi.advanceTimersByTime(50); + expect(onFull).toHaveBeenCalledTimes(1); + }); + + it("single task-updated event triggers granular batch callback with one entry after 50ms", () => { + const onFull = vi.fn(); + const onGranular = vi.fn(); + const batcher = createDomainEventBatcher(onFull, onGranular, 50); + + const taskEvent: DesktopEvent = { type: "task-updated", taskId: "t-1", action: "updated" }; + batcher.push(taskEvent); + + vi.advanceTimersByTime(50); + expect(onGranular).toHaveBeenCalledTimes(1); + expect(onFull).not.toHaveBeenCalled(); + + const entries: GranularEventEntry[] = onGranular.mock.calls[0]![0]; + expect(entries).toHaveLength(1); + expect(entries[0]).toEqual({ type: "task-updated", entityId: "t-1", action: "updated" }); + }); + + it("multiple task-updated events for SAME taskId coalesce to single entry (last-write-wins on action)", () => { + const onFull = vi.fn(); + const onGranular = vi.fn(); + const batcher = createDomainEventBatcher(onFull, onGranular, 50); + + batcher.push({ type: "task-updated", taskId: "t-1", action: "created" } as DesktopEvent); + batcher.push({ type: "task-updated", taskId: "t-1", action: "updated" } as DesktopEvent); + + vi.advanceTimersByTime(50); + const entries: GranularEventEntry[] = onGranular.mock.calls[0]![0]; + expect(entries).toHaveLength(1); + expect(entries[0]!.action).toBe("updated"); + }); + + it("multiple task-updated events for DIFFERENT taskIds produce separate entries", () => { + const onFull = vi.fn(); + const onGranular = vi.fn(); + const batcher = createDomainEventBatcher(onFull, onGranular, 50); + + batcher.push({ type: "task-updated", taskId: "t-1", action: "updated" } as DesktopEvent); + batcher.push({ type: "task-updated", taskId: "t-2", action: "created" } as DesktopEvent); + + vi.advanceTimersByTime(50); + const entries: GranularEventEntry[] = onGranular.mock.calls[0]![0]; + expect(entries).toHaveLength(2); + }); + + it("domain-changed in same window as granular events causes full refresh (domain-changed overrides)", () => { + const onFull = vi.fn(); + const onGranular = vi.fn(); + const batcher = createDomainEventBatcher(onFull, onGranular, 50); + + batcher.push({ type: "task-updated", taskId: "t-1", action: "updated" } as DesktopEvent); + batcher.push({ type: "run-updated", runId: "r-1", action: "completed" } as DesktopEvent); + batcher.push({ type: "domain-changed" } as DesktopEvent); + + vi.advanceTimersByTime(50); + expect(onFull).toHaveBeenCalledTimes(1); + expect(onGranular).not.toHaveBeenCalled(); + }); + + it("more than 10 unique granular entities in one window triggers full refresh instead of granular batch", () => { + const onFull = vi.fn(); + const onGranular = vi.fn(); + const batcher = createDomainEventBatcher(onFull, onGranular, 50); + + // Push 11 unique task-updated events + for (let i = 0; i < 11; i++) { + batcher.push({ type: "task-updated", taskId: `t-${i}`, action: "updated" } as DesktopEvent); + } + + vi.advanceTimersByTime(50); + expect(onFull).toHaveBeenCalledTimes(1); + expect(onGranular).not.toHaveBeenCalled(); + }); + + it("flush() immediately processes pending events", () => { + const onFull = vi.fn(); + const onGranular = vi.fn(); + const batcher = createDomainEventBatcher(onFull, onGranular, 50); + + batcher.push({ type: "task-updated", taskId: "t-1", action: "created" } as DesktopEvent); + batcher.flush(); + + expect(onGranular).toHaveBeenCalledTimes(1); + // Should not fire again on timer + vi.advanceTimersByTime(100); + expect(onGranular).toHaveBeenCalledTimes(1); + }); + + it("dispose() cancels pending events without firing callbacks", () => { + const onFull = vi.fn(); + const onGranular = vi.fn(); + const batcher = createDomainEventBatcher(onFull, onGranular, 50); + + batcher.push({ type: "task-updated", taskId: "t-1", action: "created" } as DesktopEvent); + batcher.dispose(); + + vi.advanceTimersByTime(100); + expect(onFull).not.toHaveBeenCalled(); + expect(onGranular).not.toHaveBeenCalled(); + }); + + it("run-log, shortcut, notification-click events are NOT handled by batcher (treated as unknown, triggers full refresh)", () => { + const onFull = vi.fn(); + const onGranular = vi.fn(); + const batcher = createDomainEventBatcher(onFull, onGranular, 50); + + // These events are not recognized as granular -- per D-05, unknown triggers full refresh + batcher.push({ type: "run-log", runId: "r-1", stream: "stdout", text: "hi" } as DesktopEvent); + + vi.advanceTimersByTime(50); + expect(onFull).toHaveBeenCalledTimes(1); + expect(onGranular).not.toHaveBeenCalled(); + }); + + it("budget-changed event appears in granular batch with companyId as entity key", () => { + const onFull = vi.fn(); + const onGranular = vi.fn(); + const batcher = createDomainEventBatcher(onFull, onGranular, 50); + + batcher.push({ type: "budget-changed", companyId: "c-1" } as DesktopEvent); + + vi.advanceTimersByTime(50); + expect(onGranular).toHaveBeenCalledTimes(1); + const entries: GranularEventEntry[] = onGranular.mock.calls[0]![0]; + expect(entries).toHaveLength(1); + expect(entries[0]).toEqual({ type: "budget-changed", entityId: "c-1", action: "changed" }); + }); + + it("if any event in window has action 'deleted', that action survives coalescing for that entity", () => { + const onFull = vi.fn(); + const onGranular = vi.fn(); + const batcher = createDomainEventBatcher(onFull, onGranular, 50); + + batcher.push({ type: "task-updated", taskId: "t-1", action: "deleted" } as DesktopEvent); + batcher.push({ type: "task-updated", taskId: "t-1", action: "updated" } as DesktopEvent); + + vi.advanceTimersByTime(50); + const entries: GranularEventEntry[] = onGranular.mock.calls[0]![0]; + expect(entries).toHaveLength(1); + expect(entries[0]!.action).toBe("deleted"); + }); +});