refactor(renderer): split snapshot state sections
This commit is contained in:
1 parent
236714e17a
commit
1383805d66
12 files changed
+1030
-899
No files matched your search
+1
-1
@@ -92,4 +92,4 @@ local-release/
|
||||
.omx/
|
||||
|
||||
# Runtime state (written by app at runtime)
|
||||
state/
|
||||
/state/
|
||||
@@ -3,6 +3,8 @@ import type { ProfileSnapshot, SectionId } from "@shared/types";
|
||||
import { describeDesktopError, unwrap } from "../lib/desktop";
|
||||
import { useAppStore } from "../state/store";
|
||||
|
||||
export type SnapshotRefreshGuard = () => boolean;
|
||||
|
||||
export function useDataLoader(
|
||||
snapshot: ProfileSnapshot | null,
|
||||
section: SectionId,
|
||||
@@ -11,10 +13,11 @@ export function useDataLoader(
|
||||
// selectedRunId via getState() at call time so its identity stays stable
|
||||
// across connector/run selection changes and AppActions consumers do not
|
||||
// cascade re-render.
|
||||
const refresh = useCallback(async () => {
|
||||
const refresh = useCallback(async (shouldApply: SnapshotRefreshGuard = () => true) => {
|
||||
const store = useAppStore.getState();
|
||||
try {
|
||||
const next = unwrap<ProfileSnapshot>(await window.agentCompany.loadSnapshot());
|
||||
if (!shouldApply()) return;
|
||||
store.setSnapshot(next);
|
||||
if (next.companies.length === 0) {
|
||||
store.setInitialOnboardingLocked(true);
|
||||
@@ -26,12 +29,15 @@ export function useDataLoader(
|
||||
if (next.currentCompanyId) {
|
||||
store.setSocialAccounts(next.socialAccounts?.filter((a) => a.companyId === next.currentCompanyId) ?? []);
|
||||
const browserActionResult = await window.agentCompany.listBrowserActions({ companyId: next.currentCompanyId, limit: 50 });
|
||||
if (!shouldApply()) return;
|
||||
if (browserActionResult.ok) {
|
||||
store.setBrowserActions(browserActionResult.data);
|
||||
}
|
||||
}
|
||||
if (!shouldApply()) return;
|
||||
store.setError(null);
|
||||
} catch (nextError) {
|
||||
if (!shouldApply()) return;
|
||||
store.setError(describeDesktopError(nextError));
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -1,37 +1,44 @@
|
||||
import { useEffect, type MutableRefObject } from "react";
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
import {
|
||||
createDomainEventBatcher,
|
||||
isSnapshotIrrelevantEvent,
|
||||
classifySnapshotEvent,
|
||||
createSnapshotEventGuard,
|
||||
type GranularEventEntry,
|
||||
} from "../lib/ipc-batcher";
|
||||
} from "../lib/snapshot-events";
|
||||
import { useAppStore } from "../state/store";
|
||||
|
||||
export function useDesktopEvents(
|
||||
refreshRef: MutableRefObject<() => Promise<void>>,
|
||||
refreshRef: MutableRefObject<(shouldApply?: () => boolean) => Promise<void>>,
|
||||
logBufferRef: MutableRefObject<Record<string, string>>,
|
||||
incrementLogRevision: () => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
void refreshRef.current();
|
||||
const store = useAppStore;
|
||||
const eventGuard = createSnapshotEventGuard();
|
||||
|
||||
async function handleGranularBatch(events: GranularEventEntry[]) {
|
||||
function requestFullRefresh() {
|
||||
eventGuard.invalidateSnapshot();
|
||||
const token = eventGuard.captureSnapshot();
|
||||
void refreshRef.current(() => eventGuard.canApplySnapshot(token));
|
||||
}
|
||||
|
||||
requestFullRefresh();
|
||||
|
||||
async function handleSnapshotEntityEvent(entry: GranularEventEntry) {
|
||||
const companyId = store.getState().snapshot?.currentCompanyId ?? null;
|
||||
if (!companyId) {
|
||||
void refreshRef.current();
|
||||
requestFullRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
let needsFullRefresh = false;
|
||||
eventGuard.invalidateSnapshot();
|
||||
eventGuard.invalidateEntity(entry);
|
||||
const token = eventGuard.captureEntity(entry);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (entry.type === "run-updated" && entry.action === "completed") {
|
||||
needsFullRefresh = true;
|
||||
} else {
|
||||
try {
|
||||
switch (entry.type) {
|
||||
case "task-updated": {
|
||||
@@ -39,6 +46,7 @@ export function useDesktopEvents(
|
||||
store.getState().removeTask(entry.entityId);
|
||||
} else {
|
||||
const result = await window.agentCompany.getTask({ companyId, id: entry.entityId });
|
||||
if (!eventGuard.canApplyEntity(entry, token)) return;
|
||||
if (result.ok) {
|
||||
store.getState().patchTask(result.data);
|
||||
} else {
|
||||
@@ -52,6 +60,7 @@ export function useDesktopEvents(
|
||||
store.getState().removeRun(entry.entityId);
|
||||
} else {
|
||||
const result = await window.agentCompany.getRun({ companyId, id: entry.entityId });
|
||||
if (!eventGuard.canApplyEntity(entry, token)) return;
|
||||
if (result.ok) {
|
||||
store.getState().patchRun(result.data);
|
||||
} else {
|
||||
@@ -65,6 +74,7 @@ export function useDesktopEvents(
|
||||
store.getState().removeApproval(entry.entityId);
|
||||
} else {
|
||||
const result = await window.agentCompany.getApproval({ companyId, id: entry.entityId });
|
||||
if (!eventGuard.canApplyEntity(entry, token)) return;
|
||||
if (result.ok) {
|
||||
store.getState().patchApproval(result.data);
|
||||
} else {
|
||||
@@ -75,6 +85,7 @@ export function useDesktopEvents(
|
||||
}
|
||||
case "agent-updated": {
|
||||
const result = await window.agentCompany.getAgent({ companyId, id: entry.entityId });
|
||||
if (!eventGuard.canApplyEntity(entry, token)) return;
|
||||
if (result.ok) {
|
||||
store.getState().patchAgent(result.data);
|
||||
} else {
|
||||
@@ -83,44 +94,24 @@ export function useDesktopEvents(
|
||||
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 (error) {
|
||||
console.warn(
|
||||
`[renderer/useDesktopEvents] granular fetch failed for ${entry.type}/${entry.entityId}; falling back to full refresh: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
} catch {
|
||||
needsFullRefresh = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (needsFullRefresh) break;
|
||||
}
|
||||
|
||||
if (needsFullRefresh) {
|
||||
void refreshRef.current();
|
||||
requestFullRefresh();
|
||||
}
|
||||
// Granular patches already update the snapshot arrays, and
|
||||
// sidebar badges derive from the snapshot live — no separate
|
||||
// badge refresh is needed.
|
||||
}
|
||||
|
||||
// 500ms window for full-refresh events (e.g. new-message,
|
||||
// thread-wait-state-updated) so bursts coalesce into one reload.
|
||||
// Granular entity events still flush at this window but are cheap.
|
||||
const batcher = createDomainEventBatcher(
|
||||
() => { void refreshRef.current(); },
|
||||
(events) => { void handleGranularBatch(events); },
|
||||
500,
|
||||
);
|
||||
|
||||
const unsubscribe = window.agentCompany.subscribe((event: DesktopEvent) => {
|
||||
if (event.type === "run-log") {
|
||||
const MAX_LOG_BYTES_PER_RUN = 512_000;
|
||||
@@ -141,24 +132,27 @@ export function useDesktopEvents(
|
||||
}
|
||||
if (event.type === "shortcut") {
|
||||
if (event.action === "toggle-command-palette") store.getState().toggleCommandPalette();
|
||||
if (event.action === "refresh") void refreshRef.current();
|
||||
if (event.action === "refresh") requestFullRefresh();
|
||||
return;
|
||||
}
|
||||
if (event.type === "notification-click") {
|
||||
store.getState().navigateTo(event.section, event.entityId);
|
||||
return;
|
||||
}
|
||||
// One-shot events that do not affect ProfileSnapshot -- handled by
|
||||
// their own listeners elsewhere, never trigger a snapshot refresh.
|
||||
if (isSnapshotIrrelevantEvent(event)) {
|
||||
|
||||
const decision = classifySnapshotEvent(event);
|
||||
if (decision.kind === "ignore") {
|
||||
return;
|
||||
}
|
||||
// All remaining data-mutating events go through the enhanced batcher
|
||||
batcher.push(event);
|
||||
if (decision.kind === "full-refresh") {
|
||||
requestFullRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
void handleSnapshotEntityEvent(decision.entry);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
batcher.dispose();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- refreshRef and logBufferRef are stable refs; incrementLogRevision is a stable Zustand setter
|
||||
}, []);
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
|
||||
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" };
|
||||
// run-status is a run-level state change -- map onto run entity so
|
||||
// granular patchRun refetches the run record.
|
||||
case "run-status":
|
||||
return { type: "run-updated", entityId: event.runId, action: "status_changed" };
|
||||
// heartbeat-status mutates agent metadata (nextWakeAt / enabled) -- map
|
||||
// onto agent entity so granular patchAgent refetches.
|
||||
case "heartbeat-status":
|
||||
return { type: "agent-updated", entityId: event.agentId, action: "updated" };
|
||||
// agent-message posts a new comment under a task; patchTask refetch will
|
||||
// pick up the task's updated comment list.
|
||||
case "agent-message":
|
||||
return { type: "task-updated", entityId: event.taskId, action: "updated" };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Events that do NOT affect ProfileSnapshot and therefore must not trigger
|
||||
// a snapshot refresh -- they are handled exclusively by their own listeners.
|
||||
const SNAPSHOT_IRRELEVANT_EVENT_TYPES = new Set<DesktopEvent["type"]>([
|
||||
"automation-fired",
|
||||
"recovery-needed",
|
||||
]);
|
||||
|
||||
export function isSnapshotIrrelevantEvent(event: DesktopEvent): boolean {
|
||||
return SNAPSHOT_IRRELEVANT_EVENT_TYPES.has(event.type);
|
||||
}
|
||||
|
||||
export function createDomainEventBatcher(
|
||||
onFullRefresh: () => void,
|
||||
onGranularBatch: (events: GranularEventEntry[]) => void,
|
||||
windowMs = 50,
|
||||
maxGranular = MAX_GRANULAR_EVENTS,
|
||||
) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let hasDomainChanged = false;
|
||||
const pending = new Map<string, GranularEventEntry>();
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
|
||||
export type SnapshotEntityEventType =
|
||||
| "task-updated"
|
||||
| "run-updated"
|
||||
| "approval-updated"
|
||||
| "agent-updated"
|
||||
| "budget-changed";
|
||||
|
||||
export interface SnapshotEntityEvent {
|
||||
type: SnapshotEntityEventType;
|
||||
entityId: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
export type GranularEventEntry = SnapshotEntityEvent;
|
||||
|
||||
export type SnapshotEventDecision =
|
||||
| { kind: "entity"; entry: SnapshotEntityEvent }
|
||||
| { kind: "full-refresh" }
|
||||
| { kind: "ignore" };
|
||||
|
||||
export interface SnapshotEntityToken {
|
||||
snapshotEpoch: number;
|
||||
entityVersion: number;
|
||||
}
|
||||
|
||||
export interface SnapshotRefreshToken {
|
||||
snapshotEpoch: number;
|
||||
}
|
||||
|
||||
export interface SnapshotEventGuard {
|
||||
invalidateSnapshot: () => void;
|
||||
captureSnapshot: () => SnapshotRefreshToken;
|
||||
canApplySnapshot: (token: SnapshotRefreshToken) => boolean;
|
||||
invalidateEntity: (entry: SnapshotEntityEvent) => void;
|
||||
captureEntity: (entry: SnapshotEntityEvent) => SnapshotEntityToken;
|
||||
canApplyEntity: (entry: SnapshotEntityEvent, token: SnapshotEntityToken) => boolean;
|
||||
}
|
||||
|
||||
export function classifySnapshotEvent(event: DesktopEvent): SnapshotEventDecision {
|
||||
switch (event.type) {
|
||||
case "task-updated":
|
||||
return { kind: "entity", entry: { type: "task-updated", entityId: event.taskId, action: event.action } };
|
||||
case "run-updated":
|
||||
return { kind: "entity", entry: { type: "run-updated", entityId: event.runId, action: event.action } };
|
||||
case "approval-updated":
|
||||
return {
|
||||
kind: "entity",
|
||||
entry: { type: "approval-updated", entityId: event.approvalId, action: event.action },
|
||||
};
|
||||
case "agent-updated":
|
||||
return { kind: "entity", entry: { type: "agent-updated", entityId: event.agentId, action: event.action } };
|
||||
case "budget-changed":
|
||||
return { kind: "entity", entry: { type: "budget-changed", entityId: event.companyId, action: "changed" } };
|
||||
case "run-status":
|
||||
return { kind: "entity", entry: { type: "run-updated", entityId: event.runId, action: "status_changed" } };
|
||||
case "heartbeat-status":
|
||||
return { kind: "entity", entry: { type: "agent-updated", entityId: event.agentId, action: "updated" } };
|
||||
case "agent-message":
|
||||
return { kind: "entity", entry: { type: "task-updated", entityId: event.taskId, action: "updated" } };
|
||||
case "automation-fired":
|
||||
case "recovery-needed":
|
||||
case "run-log":
|
||||
case "shortcut":
|
||||
case "notification-click":
|
||||
return { kind: "ignore" };
|
||||
case "domain-changed":
|
||||
case "new-message":
|
||||
case "thread-wait-state-updated":
|
||||
case "credentials-changed":
|
||||
case "agent-event":
|
||||
case "oauth:status":
|
||||
case "oauth:auth-url":
|
||||
case "oauth:progress":
|
||||
case "oauth:prompt-request":
|
||||
case "oauth:manual-code-request":
|
||||
return { kind: "full-refresh" };
|
||||
default:
|
||||
return { kind: "full-refresh" };
|
||||
}
|
||||
}
|
||||
|
||||
export function createSnapshotEventGuard(): SnapshotEventGuard {
|
||||
let snapshotEpoch = 0;
|
||||
const entityVersions = new Map<string, number>();
|
||||
|
||||
function entityKey(entry: SnapshotEntityEvent): string {
|
||||
return `${entry.type}:${entry.entityId}`;
|
||||
}
|
||||
|
||||
function entityVersion(entry: SnapshotEntityEvent): number {
|
||||
return entityVersions.get(entityKey(entry)) ?? 0;
|
||||
}
|
||||
|
||||
return {
|
||||
invalidateSnapshot() {
|
||||
snapshotEpoch += 1;
|
||||
},
|
||||
captureSnapshot() {
|
||||
return { snapshotEpoch };
|
||||
},
|
||||
canApplySnapshot(token) {
|
||||
return snapshotEpoch === token.snapshotEpoch;
|
||||
},
|
||||
invalidateEntity(entry) {
|
||||
const key = entityKey(entry);
|
||||
entityVersions.set(key, (entityVersions.get(key) ?? 0) + 1);
|
||||
},
|
||||
captureEntity(entry) {
|
||||
return {
|
||||
snapshotEpoch,
|
||||
entityVersion: entityVersion(entry),
|
||||
};
|
||||
},
|
||||
canApplyEntity(entry, token) {
|
||||
return snapshotEpoch === token.snapshotEpoch && entityVersion(entry) === token.entityVersion;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
type AppErrorInput,
|
||||
type AppErrorNotice,
|
||||
} from "../../lib/desktop";
|
||||
import {
|
||||
assembleProfileSnapshot,
|
||||
splitProfileSnapshot,
|
||||
type ProfileSnapshotSections,
|
||||
} from "../snapshot-sections";
|
||||
|
||||
// Sidebar badge counts and inbox items both live in the renderer —
|
||||
// derived on every read from the snapshot (see `@shared/sidebar-badges`
|
||||
@@ -24,6 +29,7 @@ import {
|
||||
// slice is what let the counters drift against the rendered content.
|
||||
export interface CompanySlice {
|
||||
snapshot: ProfileSnapshot | null;
|
||||
snapshotSections: ProfileSnapshotSections | null;
|
||||
/**
|
||||
* Bumps only on full-snapshot replacements (setSnapshot / refreshSnapshot).
|
||||
* Granular patch/remove methods DO NOT bump this counter so that
|
||||
@@ -49,6 +55,14 @@ export interface CompanySlice {
|
||||
removeApproval: (approvalId: string) => void;
|
||||
}
|
||||
|
||||
function applySnapshotSections(
|
||||
state: CompanySlice,
|
||||
sections: ProfileSnapshotSections | null,
|
||||
) {
|
||||
state.snapshotSections = sections;
|
||||
state.snapshot = sections ? assembleProfileSnapshot(sections) : null;
|
||||
}
|
||||
|
||||
export const createCompanySlice: StateCreator<
|
||||
AppState,
|
||||
[["zustand/immer", never]],
|
||||
@@ -56,13 +70,14 @@ export const createCompanySlice: StateCreator<
|
||||
CompanySlice
|
||||
> = (set) => ({
|
||||
snapshot: null,
|
||||
snapshotSections: null,
|
||||
snapshotRevision: 0,
|
||||
companyMetrics: null,
|
||||
standupReport: null,
|
||||
error: null,
|
||||
setSnapshot: (snapshot) =>
|
||||
set((state) => {
|
||||
state.snapshot = snapshot;
|
||||
applySnapshotSections(state, snapshot ? splitProfileSnapshot(snapshot) : null);
|
||||
state.snapshotRevision += 1;
|
||||
}),
|
||||
setCompanyMetrics: (metrics) =>
|
||||
@@ -82,7 +97,7 @@ export const createCompanySlice: StateCreator<
|
||||
const result = await window.agentCompany.loadSnapshot();
|
||||
if (result.ok) {
|
||||
set((state) => {
|
||||
state.snapshot = result.data;
|
||||
applySnapshotSections(state, splitProfileSnapshot(result.data));
|
||||
state.snapshotRevision += 1;
|
||||
});
|
||||
} else {
|
||||
@@ -101,65 +116,85 @@ export const createCompanySlice: StateCreator<
|
||||
|
||||
patchTask: (task) =>
|
||||
set((state) => {
|
||||
if (!state.snapshot) return;
|
||||
const idx = state.snapshot.tasks.findIndex((t) => t.id === task.id);
|
||||
if (!state.snapshotSections) return;
|
||||
const tasks = state.snapshotSections.company.tasks;
|
||||
const idx = tasks.findIndex((t) => t.id === task.id);
|
||||
if (idx >= 0) {
|
||||
state.snapshot.tasks[idx] = task;
|
||||
tasks[idx] = task;
|
||||
} else {
|
||||
state.snapshot.tasks.unshift(task);
|
||||
tasks.unshift(task);
|
||||
}
|
||||
applySnapshotSections(state, state.snapshotSections);
|
||||
}),
|
||||
removeTask: (taskId) =>
|
||||
set((state) => {
|
||||
if (!state.snapshot) return;
|
||||
state.snapshot.tasks = state.snapshot.tasks.filter((t) => t.id !== taskId);
|
||||
if (!state.snapshotSections) return;
|
||||
state.snapshotSections.company.tasks = state.snapshotSections.company.tasks.filter(
|
||||
(t) => t.id !== taskId,
|
||||
);
|
||||
applySnapshotSections(state, state.snapshotSections);
|
||||
}),
|
||||
|
||||
patchRun: (run) =>
|
||||
set((state) => {
|
||||
if (!state.snapshot) return;
|
||||
const idx = state.snapshot.runs.findIndex((r) => r.id === run.id);
|
||||
if (!state.snapshotSections) return;
|
||||
const runs = state.snapshotSections.company.runs;
|
||||
const idx = runs.findIndex((r) => r.id === run.id);
|
||||
if (idx >= 0) {
|
||||
state.snapshot.runs[idx] = run;
|
||||
runs[idx] = run;
|
||||
} else {
|
||||
state.snapshot.runs.unshift(run);
|
||||
runs.unshift(run);
|
||||
}
|
||||
applySnapshotSections(state, state.snapshotSections);
|
||||
}),
|
||||
removeRun: (runId) =>
|
||||
set((state) => {
|
||||
if (!state.snapshot) return;
|
||||
state.snapshot.runs = state.snapshot.runs.filter((r) => r.id !== runId);
|
||||
if (!state.snapshotSections) return;
|
||||
state.snapshotSections.company.runs = state.snapshotSections.company.runs.filter(
|
||||
(r) => r.id !== runId,
|
||||
);
|
||||
applySnapshotSections(state, state.snapshotSections);
|
||||
}),
|
||||
|
||||
patchAgent: (agent) =>
|
||||
set((state) => {
|
||||
if (!state.snapshot) return;
|
||||
const idx = state.snapshot.agents.findIndex((a) => a.id === agent.id);
|
||||
if (!state.snapshotSections) return;
|
||||
const agents = state.snapshotSections.company.agents;
|
||||
const idx = agents.findIndex((a) => a.id === agent.id);
|
||||
if (idx >= 0) {
|
||||
state.snapshot.agents[idx] = agent;
|
||||
agents[idx] = agent;
|
||||
} else {
|
||||
state.snapshot.agents.unshift(agent);
|
||||
agents.unshift(agent);
|
||||
}
|
||||
applySnapshotSections(state, state.snapshotSections);
|
||||
}),
|
||||
removeAgent: (agentId) =>
|
||||
set((state) => {
|
||||
if (!state.snapshot) return;
|
||||
state.snapshot.agents = state.snapshot.agents.filter((a) => a.id !== agentId);
|
||||
if (!state.snapshotSections) return;
|
||||
state.snapshotSections.company.agents = state.snapshotSections.company.agents.filter(
|
||||
(a) => a.id !== agentId,
|
||||
);
|
||||
applySnapshotSections(state, state.snapshotSections);
|
||||
}),
|
||||
|
||||
patchApproval: (approval) =>
|
||||
set((state) => {
|
||||
if (!state.snapshot) return;
|
||||
const idx = state.snapshot.approvals.findIndex((a) => a.id === approval.id);
|
||||
if (!state.snapshotSections) return;
|
||||
const approvals = state.snapshotSections.company.approvals;
|
||||
const idx = approvals.findIndex((a) => a.id === approval.id);
|
||||
if (idx >= 0) {
|
||||
state.snapshot.approvals[idx] = approval;
|
||||
approvals[idx] = approval;
|
||||
} else {
|
||||
state.snapshot.approvals.unshift(approval);
|
||||
approvals.unshift(approval);
|
||||
}
|
||||
applySnapshotSections(state, state.snapshotSections);
|
||||
}),
|
||||
removeApproval: (approvalId) =>
|
||||
set((state) => {
|
||||
if (!state.snapshot) return;
|
||||
state.snapshot.approvals = state.snapshot.approvals.filter((a) => a.id !== approvalId);
|
||||
if (!state.snapshotSections) return;
|
||||
state.snapshotSections.company.approvals = state.snapshotSections.company.approvals.filter(
|
||||
(a) => a.id !== approvalId,
|
||||
);
|
||||
applySnapshotSections(state, state.snapshotSections);
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { ProfileSnapshot } from "@shared/types";
|
||||
|
||||
export interface ProfileMetadataSection {
|
||||
currentCompanyId: ProfileSnapshot["currentCompanyId"];
|
||||
profilePath: ProfileSnapshot["profilePath"];
|
||||
updatesEnabled: ProfileSnapshot["updatesEnabled"];
|
||||
theme: ProfileSnapshot["theme"];
|
||||
locale: ProfileSnapshot["locale"];
|
||||
backend: ProfileSnapshot["backend"];
|
||||
}
|
||||
|
||||
export interface ProfileCatalogSection {
|
||||
companies: ProfileSnapshot["companies"];
|
||||
connectors: ProfileSnapshot["connectors"];
|
||||
}
|
||||
|
||||
export interface CompanyCollectionsSection {
|
||||
workspaces: ProfileSnapshot["workspaces"];
|
||||
agents: ProfileSnapshot["agents"];
|
||||
goals: ProfileSnapshot["goals"];
|
||||
projects: ProfileSnapshot["projects"];
|
||||
tasks: ProfileSnapshot["tasks"];
|
||||
approvals: ProfileSnapshot["approvals"];
|
||||
runs: ProfileSnapshot["runs"];
|
||||
costs: ProfileSnapshot["costs"];
|
||||
activity: ProfileSnapshot["activity"];
|
||||
secrets: ProfileSnapshot["secrets"];
|
||||
comments: ProfileSnapshot["comments"];
|
||||
socialAccounts: ProfileSnapshot["socialAccounts"];
|
||||
meetings: ProfileSnapshot["meetings"];
|
||||
documents: ProfileSnapshot["documents"];
|
||||
workProducts: ProfileSnapshot["workProducts"];
|
||||
knowledgeBase: ProfileSnapshot["knowledgeBase"];
|
||||
sprints: ProfileSnapshot["sprints"];
|
||||
automationRules: ProfileSnapshot["automationRules"];
|
||||
agentMessages: ProfileSnapshot["agentMessages"];
|
||||
workflows: ProfileSnapshot["workflows"];
|
||||
}
|
||||
|
||||
export interface ProfileSnapshotSections {
|
||||
metadata: ProfileMetadataSection;
|
||||
catalog: ProfileCatalogSection;
|
||||
company: CompanyCollectionsSection;
|
||||
}
|
||||
|
||||
export function splitProfileSnapshot(snapshot: ProfileSnapshot): ProfileSnapshotSections {
|
||||
return {
|
||||
metadata: {
|
||||
currentCompanyId: snapshot.currentCompanyId,
|
||||
profilePath: snapshot.profilePath,
|
||||
updatesEnabled: snapshot.updatesEnabled,
|
||||
theme: snapshot.theme,
|
||||
locale: snapshot.locale,
|
||||
backend: snapshot.backend,
|
||||
},
|
||||
catalog: {
|
||||
companies: snapshot.companies,
|
||||
connectors: snapshot.connectors,
|
||||
},
|
||||
company: {
|
||||
workspaces: snapshot.workspaces,
|
||||
agents: snapshot.agents,
|
||||
goals: snapshot.goals,
|
||||
projects: snapshot.projects,
|
||||
tasks: snapshot.tasks,
|
||||
approvals: snapshot.approvals,
|
||||
runs: snapshot.runs,
|
||||
costs: snapshot.costs,
|
||||
activity: snapshot.activity,
|
||||
secrets: snapshot.secrets,
|
||||
comments: snapshot.comments,
|
||||
socialAccounts: snapshot.socialAccounts,
|
||||
meetings: snapshot.meetings,
|
||||
documents: snapshot.documents,
|
||||
workProducts: snapshot.workProducts,
|
||||
knowledgeBase: snapshot.knowledgeBase,
|
||||
sprints: snapshot.sprints,
|
||||
automationRules: snapshot.automationRules,
|
||||
agentMessages: snapshot.agentMessages,
|
||||
workflows: snapshot.workflows,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function assembleProfileSnapshot(sections: ProfileSnapshotSections): ProfileSnapshot {
|
||||
return {
|
||||
currentCompanyId: sections.metadata.currentCompanyId,
|
||||
profilePath: sections.metadata.profilePath,
|
||||
updatesEnabled: sections.metadata.updatesEnabled,
|
||||
theme: sections.metadata.theme,
|
||||
locale: sections.metadata.locale,
|
||||
backend: sections.metadata.backend,
|
||||
companies: sections.catalog.companies,
|
||||
connectors: sections.catalog.connectors,
|
||||
workspaces: sections.company.workspaces,
|
||||
agents: sections.company.agents,
|
||||
goals: sections.company.goals,
|
||||
projects: sections.company.projects,
|
||||
tasks: sections.company.tasks,
|
||||
approvals: sections.company.approvals,
|
||||
runs: sections.company.runs,
|
||||
costs: sections.company.costs,
|
||||
activity: sections.company.activity,
|
||||
secrets: sections.company.secrets,
|
||||
comments: sections.company.comments,
|
||||
socialAccounts: sections.company.socialAccounts,
|
||||
meetings: sections.company.meetings,
|
||||
documents: sections.company.documents,
|
||||
workProducts: sections.company.workProducts,
|
||||
knowledgeBase: sections.company.knowledgeBase,
|
||||
sprints: sections.company.sprints,
|
||||
automationRules: sections.company.automationRules,
|
||||
agentMessages: sections.company.agentMessages,
|
||||
workflows: sections.company.workflows,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { assembleProfileSnapshot } from "@renderer/state/snapshot-sections";
|
||||
import { useAppStore } from "@renderer/state/store";
|
||||
import type { AgentRecord, ApprovalRecord, ProfileSnapshot, RunRecord, TaskRecord } from "@shared/types";
|
||||
|
||||
function makeSnapshot(overrides: Partial<ProfileSnapshot> = {}): ProfileSnapshot {
|
||||
return {
|
||||
currentCompanyId: "company-1",
|
||||
profilePath: "/test/path",
|
||||
updatesEnabled: true,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
backend: { secretBackend: "safe_storage" },
|
||||
companies: [],
|
||||
connectors: [],
|
||||
workspaces: [],
|
||||
agents: [],
|
||||
goals: [],
|
||||
projects: [],
|
||||
tasks: [],
|
||||
approvals: [],
|
||||
runs: [],
|
||||
costs: [],
|
||||
activity: [],
|
||||
secrets: [],
|
||||
comments: [],
|
||||
socialAccounts: [],
|
||||
meetings: [],
|
||||
documents: [],
|
||||
workProducts: [],
|
||||
knowledgeBase: [],
|
||||
sprints: [],
|
||||
automationRules: [],
|
||||
agentMessages: [],
|
||||
workflows: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function expectSnapshotSectionsMatchSnapshot() {
|
||||
const { snapshot, snapshotSections } = useAppStore.getState();
|
||||
expect(snapshot).toEqual(snapshotSections ? assembleProfileSnapshot(snapshotSections) : null);
|
||||
}
|
||||
|
||||
const fakeTask = (id: string, title = "Task"): TaskRecord => ({
|
||||
id,
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
title,
|
||||
description: "",
|
||||
assigneeAgentId: null,
|
||||
workspaceId: null,
|
||||
status: "open",
|
||||
priority: "medium",
|
||||
activeRunId: null,
|
||||
sessionJson: null,
|
||||
requiresUserReview: false,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
} as unknown as TaskRecord);
|
||||
|
||||
const fakeRun = (id: string): RunRecord => ({
|
||||
id,
|
||||
companyId: "company-1",
|
||||
taskId: "t-1",
|
||||
agentId: "ag-1",
|
||||
workspaceId: null,
|
||||
connectorId: "claude-pro",
|
||||
status: "queued",
|
||||
summary: "",
|
||||
errorMessage: null,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
model: null,
|
||||
unattributedCost: false,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
logPath: null,
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
} as RunRecord);
|
||||
|
||||
const fakeAgent = (id: string, name = "Agent"): AgentRecord => ({
|
||||
id,
|
||||
companyId: "company-1",
|
||||
name,
|
||||
role: "engineer",
|
||||
title: "",
|
||||
department: "engineering",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "claude-pro",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "",
|
||||
budgetMonthlyUsd: 100,
|
||||
spentMonthlyUsd: 0,
|
||||
metadataJson: "{}",
|
||||
heartbeatEnabled: false,
|
||||
heartbeatIntervalSec: 0,
|
||||
lastHeartbeatAt: null,
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
} as AgentRecord);
|
||||
|
||||
const fakeApproval = (id: string): ApprovalRecord => ({
|
||||
id,
|
||||
companyId: "company-1",
|
||||
relatedTaskId: "t-1",
|
||||
relatedAgentId: null,
|
||||
requestedByAgentId: "ag-1",
|
||||
type: "task_completion",
|
||||
payloadSummary: "",
|
||||
impactSummary: "",
|
||||
payloadJson: null,
|
||||
decisionNote: null,
|
||||
state: "pending",
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
} as unknown as ApprovalRecord);
|
||||
|
||||
describe("CompanySlice patch methods", () => {
|
||||
beforeEach(() => {
|
||||
useAppStore.setState(useAppStore.getInitialState());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("patchTask inserts new task at beginning when task ID is missing", () => {
|
||||
useAppStore.getState().setSnapshot(makeSnapshot({ tasks: [fakeTask("t-existing")] }));
|
||||
|
||||
useAppStore.getState().patchTask(fakeTask("t-new", "New Task"));
|
||||
|
||||
const tasks = useAppStore.getState().snapshot!.tasks;
|
||||
expect(tasks.map((task) => task.id)).toEqual(["t-new", "t-existing"]);
|
||||
expect(useAppStore.getState().snapshotSections!.company.tasks.map((task) => task.id)).toEqual([
|
||||
"t-new",
|
||||
"t-existing",
|
||||
]);
|
||||
expectSnapshotSectionsMatchSnapshot();
|
||||
});
|
||||
|
||||
it("patchTask updates existing task in both section and compatibility snapshot", () => {
|
||||
useAppStore.getState().setSnapshot(makeSnapshot({ tasks: [fakeTask("t-1", "Original")] }));
|
||||
|
||||
useAppStore.getState().patchTask(fakeTask("t-1", "Updated"));
|
||||
|
||||
expect(useAppStore.getState().snapshot!.tasks[0]!.title).toBe("Updated");
|
||||
expect(useAppStore.getState().snapshotSections!.company.tasks[0]!.title).toBe("Updated");
|
||||
expectSnapshotSectionsMatchSnapshot();
|
||||
});
|
||||
|
||||
it("removeTask filters task by ID in both section and compatibility snapshot", () => {
|
||||
useAppStore.getState().setSnapshot(makeSnapshot({ tasks: [fakeTask("t-1"), fakeTask("t-2")] }));
|
||||
|
||||
useAppStore.getState().removeTask("t-1");
|
||||
|
||||
expect(useAppStore.getState().snapshot!.tasks.map((task) => task.id)).toEqual(["t-2"]);
|
||||
expect(useAppStore.getState().snapshotSections!.company.tasks.map((task) => task.id)).toEqual(["t-2"]);
|
||||
expectSnapshotSectionsMatchSnapshot();
|
||||
});
|
||||
|
||||
it("patchRun inserts and updates run records through snapshot sections", () => {
|
||||
useAppStore.getState().setSnapshot(makeSnapshot({ runs: [fakeRun("r-existing")] }));
|
||||
|
||||
useAppStore.getState().patchRun(fakeRun("r-new"));
|
||||
useAppStore.getState().patchRun({ ...fakeRun("r-new"), status: "running" });
|
||||
|
||||
expect(useAppStore.getState().snapshot!.runs.map((run) => `${run.id}:${run.status}`)).toEqual([
|
||||
"r-new:running",
|
||||
"r-existing:queued",
|
||||
]);
|
||||
expect(useAppStore.getState().snapshotSections!.company.runs[0]!.status).toBe("running");
|
||||
expectSnapshotSectionsMatchSnapshot();
|
||||
});
|
||||
|
||||
it("removeRun filters run by ID in both section and compatibility snapshot", () => {
|
||||
useAppStore.getState().setSnapshot(makeSnapshot({ runs: [fakeRun("r-1"), fakeRun("r-2")] }));
|
||||
|
||||
useAppStore.getState().removeRun("r-1");
|
||||
|
||||
expect(useAppStore.getState().snapshot!.runs.map((run) => run.id)).toEqual(["r-2"]);
|
||||
expect(useAppStore.getState().snapshotSections!.company.runs.map((run) => run.id)).toEqual(["r-2"]);
|
||||
expectSnapshotSectionsMatchSnapshot();
|
||||
});
|
||||
|
||||
it("patchAgent inserts and updates agent records through snapshot sections", () => {
|
||||
useAppStore.getState().setSnapshot(makeSnapshot({ agents: [fakeAgent("ag-existing")] }));
|
||||
|
||||
useAppStore.getState().patchAgent(fakeAgent("ag-new", "New Agent"));
|
||||
useAppStore.getState().patchAgent(fakeAgent("ag-new", "Renamed Agent"));
|
||||
|
||||
expect(useAppStore.getState().snapshot!.agents.map((agent) => `${agent.id}:${agent.name}`)).toEqual([
|
||||
"ag-new:Renamed Agent",
|
||||
"ag-existing:Agent",
|
||||
]);
|
||||
expect(useAppStore.getState().snapshotSections!.company.agents[0]!.name).toBe("Renamed Agent");
|
||||
expectSnapshotSectionsMatchSnapshot();
|
||||
});
|
||||
|
||||
it("removeAgent filters agent by ID in both section and compatibility snapshot", () => {
|
||||
useAppStore.getState().setSnapshot(makeSnapshot({ agents: [fakeAgent("ag-1"), fakeAgent("ag-2")] }));
|
||||
|
||||
useAppStore.getState().removeAgent("ag-1");
|
||||
|
||||
expect(useAppStore.getState().snapshot!.agents.map((agent) => agent.id)).toEqual(["ag-2"]);
|
||||
expect(useAppStore.getState().snapshotSections!.company.agents.map((agent) => agent.id)).toEqual(["ag-2"]);
|
||||
expectSnapshotSectionsMatchSnapshot();
|
||||
});
|
||||
|
||||
it("patchApproval inserts and updates approval records through snapshot sections", () => {
|
||||
useAppStore.getState().setSnapshot(makeSnapshot({ approvals: [fakeApproval("ap-existing")] }));
|
||||
|
||||
useAppStore.getState().patchApproval(fakeApproval("ap-new"));
|
||||
useAppStore.getState().patchApproval({ ...fakeApproval("ap-new"), state: "approved" });
|
||||
|
||||
expect(useAppStore.getState().snapshot!.approvals.map((approval) => `${approval.id}:${approval.state}`)).toEqual([
|
||||
"ap-new:approved",
|
||||
"ap-existing:pending",
|
||||
]);
|
||||
expect(useAppStore.getState().snapshotSections!.company.approvals[0]!.state).toBe("approved");
|
||||
expectSnapshotSectionsMatchSnapshot();
|
||||
});
|
||||
|
||||
it("removeApproval filters approval by ID in both section and compatibility snapshot", () => {
|
||||
useAppStore.getState().setSnapshot(makeSnapshot({ approvals: [fakeApproval("ap-1"), fakeApproval("ap-2")] }));
|
||||
|
||||
useAppStore.getState().removeApproval("ap-1");
|
||||
|
||||
expect(useAppStore.getState().snapshot!.approvals.map((approval) => approval.id)).toEqual(["ap-2"]);
|
||||
expect(useAppStore.getState().snapshotSections!.company.approvals.map((approval) => approval.id)).toEqual([
|
||||
"ap-2",
|
||||
]);
|
||||
expectSnapshotSectionsMatchSnapshot();
|
||||
});
|
||||
|
||||
it("patch methods leave null snapshot state unchanged", () => {
|
||||
expect(useAppStore.getState().snapshot).toBeNull();
|
||||
|
||||
useAppStore.getState().patchTask(fakeTask("t-1"));
|
||||
useAppStore.getState().removeTask("t-1");
|
||||
useAppStore.getState().patchRun(fakeRun("r-1"));
|
||||
useAppStore.getState().removeRun("r-1");
|
||||
useAppStore.getState().patchAgent(fakeAgent("ag-1"));
|
||||
useAppStore.getState().removeAgent("ag-1");
|
||||
useAppStore.getState().patchApproval(fakeApproval("ap-1"));
|
||||
useAppStore.getState().removeApproval("ap-1");
|
||||
|
||||
expect(useAppStore.getState().snapshot).toBeNull();
|
||||
expect(useAppStore.getState().snapshotSections).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,181 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createDomainEventBatcher, type GranularEventEntry } from "@renderer/lib/ipc-batcher";
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
|
||||
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", chunk: "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");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type {
|
||||
DesktopEvent,
|
||||
TaskUpdatedEvent,
|
||||
@@ -7,7 +7,9 @@ import type {
|
||||
AgentUpdatedEvent,
|
||||
BudgetChangedEvent,
|
||||
} from "@shared/types";
|
||||
import type { GranularEventEntry } from "@renderer/lib/ipc-batcher";
|
||||
import {
|
||||
classifySnapshotEvent,
|
||||
} from "@renderer/lib/snapshot-events";
|
||||
|
||||
describe("selective IPC event types", () => {
|
||||
it("TaskUpdatedEvent discriminated by type field", () => {
|
||||
@@ -63,7 +65,7 @@ describe("selective IPC event types", () => {
|
||||
expect(event.companyId).toBe("c-1");
|
||||
});
|
||||
|
||||
it("all five new event types are assignable to DesktopEvent union", () => {
|
||||
it("all five entity event types are assignable to DesktopEvent union", () => {
|
||||
const events: DesktopEvent[] = [
|
||||
{ type: "task-updated", taskId: "t-1", action: "created" },
|
||||
{ type: "run-updated", runId: "r-1", action: "status_changed" },
|
||||
@@ -74,8 +76,7 @@ describe("selective IPC event types", () => {
|
||||
expect(events).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("DesktopEvent union has all 14 members (9 original + 5 new)", () => {
|
||||
// This is a compile-time check -- if any type is missing, TypeScript will error
|
||||
it("DesktopEvent union has all members used by snapshot classification", () => {
|
||||
const exhaustiveCheck = (event: DesktopEvent): string => {
|
||||
switch (event.type) {
|
||||
case "run-log":
|
||||
@@ -130,262 +131,85 @@ describe("selective IPC event types", () => {
|
||||
}
|
||||
}
|
||||
};
|
||||
// Dummy call to avoid unused-var warning
|
||||
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?: unknown }>;
|
||||
getRun: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: unknown }>;
|
||||
getApproval: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: unknown }>;
|
||||
getAgent: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: unknown }>;
|
||||
patchTask: (task: unknown) => void;
|
||||
removeTask: (id: string) => void;
|
||||
patchRun: (run: unknown) => void;
|
||||
removeRun: (id: string) => void;
|
||||
patchApproval: (approval: unknown) => void;
|
||||
removeApproval: (id: string) => void;
|
||||
patchAgent: (agent: unknown) => void;
|
||||
refreshBadges: () => Promise<void>;
|
||||
fullRefresh: () => void;
|
||||
},
|
||||
) {
|
||||
if (!deps.companyId) {
|
||||
deps.fullRefresh();
|
||||
return;
|
||||
}
|
||||
describe("classifySnapshotEvent", () => {
|
||||
it("maps direct entity events to one immediate entity decision", () => {
|
||||
expect(classifySnapshotEvent({ type: "task-updated", taskId: "t-1", action: "updated" })).toEqual({
|
||||
kind: "entity",
|
||||
entry: { type: "task-updated", entityId: "t-1", action: "updated" },
|
||||
});
|
||||
expect(classifySnapshotEvent({ type: "run-updated", runId: "r-1", action: "status_changed" })).toEqual({
|
||||
kind: "entity",
|
||||
entry: { type: "run-updated", entityId: "r-1", action: "status_changed" },
|
||||
});
|
||||
expect(classifySnapshotEvent({ type: "approval-updated", approvalId: "a-1", action: "decided" })).toEqual({
|
||||
kind: "entity",
|
||||
entry: { type: "approval-updated", entityId: "a-1", action: "decided" },
|
||||
});
|
||||
expect(classifySnapshotEvent({ type: "agent-updated", agentId: "ag-1", action: "paused" })).toEqual({
|
||||
kind: "entity",
|
||||
entry: { type: "agent-updated", entityId: "ag-1", action: "paused" },
|
||||
});
|
||||
expect(classifySnapshotEvent({ type: "budget-changed", companyId: "company-1" })).toEqual({
|
||||
kind: "entity",
|
||||
entry: { type: "budget-changed", entityId: "company-1", action: "changed" },
|
||||
});
|
||||
});
|
||||
|
||||
let needsFullRefresh = false;
|
||||
it("maps legacy progress events to their owning entities", () => {
|
||||
expect(classifySnapshotEvent({ type: "run-status", runId: "r-1", status: "running", message: "Running" })).toEqual({
|
||||
kind: "entity",
|
||||
entry: { type: "run-updated", entityId: "r-1", action: "status_changed" },
|
||||
});
|
||||
expect(classifySnapshotEvent({ type: "heartbeat-status", agentId: "ag-1", enabled: true, nextWakeAt: null })).toEqual({
|
||||
kind: "entity",
|
||||
entry: { type: "agent-updated", entityId: "ag-1", action: "updated" },
|
||||
});
|
||||
expect(classifySnapshotEvent({ type: "agent-message", taskId: "t-1", comment: {} } as DesktopEvent)).toEqual({
|
||||
kind: "entity",
|
||||
entry: { type: "task-updated", entityId: "t-1", action: "updated" },
|
||||
});
|
||||
});
|
||||
|
||||
for (const entry of events) {
|
||||
if (entry.type === "run-updated" && entry.action === "completed") {
|
||||
needsFullRefresh = true;
|
||||
break;
|
||||
it("routes domain-wide events to full refresh", () => {
|
||||
const events: DesktopEvent[] = [
|
||||
{ type: "domain-changed" },
|
||||
{ type: "new-message" } as DesktopEvent,
|
||||
{ type: "thread-wait-state-updated", threadId: "th-1", companyId: "c-1", waitStateId: "w-1", action: "created" },
|
||||
{ type: "credentials-changed", connectorId: "claude-pro" },
|
||||
{ type: "agent-event", runId: "r-1", agentId: "ag-1", companyId: "c-1", taskId: "t-1", emittedAt: "2026-01-01", event: { type: "x" } },
|
||||
{ type: "oauth:status", connectorId: "claude-pro", status: "active" },
|
||||
{ type: "oauth:auth-url", connectorId: "claude-pro", url: "https://example.com" },
|
||||
{ type: "oauth:progress", connectorId: "claude-pro", message: "Working" },
|
||||
{ type: "oauth:prompt-request", connectorId: "claude-pro", requestId: "req-1", message: "Code" },
|
||||
{ type: "oauth:manual-code-request", connectorId: "claude-pro", requestId: "req-1" },
|
||||
];
|
||||
|
||||
for (const event of events) {
|
||||
expect(classifySnapshotEvent(event)).toEqual({ kind: "full-refresh" });
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
it("ignores events with independent listeners", () => {
|
||||
const events: DesktopEvent[] = [
|
||||
{ type: "automation-fired", ruleId: "rule-1", ruleName: "Rule", trigger: "schedule", action: "run_task" },
|
||||
{ type: "recovery-needed", interruptedRuns: [] },
|
||||
{ type: "run-log", runId: "r-1", stream: "stdout", chunk: "chunk" },
|
||||
{ type: "shortcut", action: "refresh" },
|
||||
{ type: "notification-click", section: "tasks", entityId: "t-1" },
|
||||
];
|
||||
|
||||
for (const event of events) {
|
||||
expect(classifySnapshotEvent(event)).toEqual({ kind: "ignore" });
|
||||
}
|
||||
|
||||
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();
|
||||
it("routes unknown runtime event shapes to full refresh", () => {
|
||||
expect(classifySnapshotEvent({ type: "future-event" } as unknown as DesktopEvent)).toEqual({
|
||||
kind: "full-refresh",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createSnapshotEventGuard,
|
||||
type GranularEventEntry,
|
||||
type SnapshotEventGuard,
|
||||
} from "@renderer/lib/snapshot-events";
|
||||
|
||||
async function handleSnapshotEntityEvent(
|
||||
entry: GranularEventEntry,
|
||||
deps: {
|
||||
companyId: string | null;
|
||||
getTask: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: unknown }>;
|
||||
getRun: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: unknown }>;
|
||||
getApproval: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: unknown }>;
|
||||
getAgent: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: unknown }>;
|
||||
patchTask: (task: unknown) => void;
|
||||
removeTask: (id: string) => void;
|
||||
patchRun: (run: unknown) => void;
|
||||
removeRun: (id: string) => void;
|
||||
patchApproval: (approval: unknown) => void;
|
||||
removeApproval: (id: string) => void;
|
||||
patchAgent: (agent: unknown) => void;
|
||||
fullRefresh: () => void;
|
||||
},
|
||||
eventGuard: SnapshotEventGuard = createSnapshotEventGuard(),
|
||||
) {
|
||||
function requestFullRefresh() {
|
||||
eventGuard.invalidateSnapshot();
|
||||
deps.fullRefresh();
|
||||
}
|
||||
|
||||
if (!deps.companyId) {
|
||||
requestFullRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
let needsFullRefresh = false;
|
||||
eventGuard.invalidateSnapshot();
|
||||
eventGuard.invalidateEntity(entry);
|
||||
const token = eventGuard.captureEntity(entry);
|
||||
|
||||
if (entry.type === "run-updated" && entry.action === "completed") {
|
||||
needsFullRefresh = true;
|
||||
} else {
|
||||
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 (!eventGuard.canApplyEntity(entry, token)) return;
|
||||
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 (!eventGuard.canApplyEntity(entry, token)) return;
|
||||
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 (!eventGuard.canApplyEntity(entry, token)) return;
|
||||
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 (!eventGuard.canApplyEntity(entry, token)) return;
|
||||
if (result.ok) {
|
||||
deps.patchAgent(result.data);
|
||||
} else {
|
||||
needsFullRefresh = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "budget-changed": {
|
||||
needsFullRefresh = true;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
needsFullRefresh = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
needsFullRefresh = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsFullRefresh) {
|
||||
requestFullRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
fullRefresh: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("snapshot entity guard", () => {
|
||||
it("task-updated event calls getTask and patchTask", async () => {
|
||||
const deps = createMockDeps();
|
||||
await handleSnapshotEntityEvent(
|
||||
{ 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).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("deleted task calls removeTask without fetching", async () => {
|
||||
const deps = createMockDeps();
|
||||
await handleSnapshotEntityEvent(
|
||||
{ type: "task-updated", entityId: "t-1", action: "deleted" },
|
||||
deps,
|
||||
);
|
||||
expect(deps.removeTask).toHaveBeenCalledWith("t-1");
|
||||
expect(deps.getTask).toHaveBeenCalledTimes(0);
|
||||
expect(deps.fullRefresh).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("task delete invalidates a pending full snapshot refresh token", async () => {
|
||||
const deps = createMockDeps();
|
||||
const eventGuard = createSnapshotEventGuard();
|
||||
eventGuard.invalidateSnapshot();
|
||||
const pendingRefresh = eventGuard.captureSnapshot();
|
||||
|
||||
await handleSnapshotEntityEvent(
|
||||
{ type: "task-updated", entityId: "t-1", action: "deleted" },
|
||||
deps,
|
||||
eventGuard,
|
||||
);
|
||||
|
||||
expect(eventGuard.canApplySnapshot(pendingRefresh)).toBe(false);
|
||||
expect(deps.removeTask).toHaveBeenCalledWith("t-1");
|
||||
});
|
||||
|
||||
it("delayed task update cannot resurrect a task deleted by a later event", async () => {
|
||||
const deps = createMockDeps();
|
||||
const eventGuard = createSnapshotEventGuard();
|
||||
const taskFetch = deferred<{ ok: boolean; data: { id: string; title: string } }>();
|
||||
deps.getTask.mockReturnValue(taskFetch.promise);
|
||||
|
||||
const update = handleSnapshotEntityEvent(
|
||||
{ type: "task-updated", entityId: "t-1", action: "updated" },
|
||||
deps,
|
||||
eventGuard,
|
||||
);
|
||||
expect(deps.getTask).toHaveBeenCalledWith({ companyId: "company-1", id: "t-1" });
|
||||
|
||||
await handleSnapshotEntityEvent(
|
||||
{ type: "task-updated", entityId: "t-1", action: "deleted" },
|
||||
deps,
|
||||
eventGuard,
|
||||
);
|
||||
taskFetch.resolve({ ok: true, data: { id: "t-1", title: "Stale task" } });
|
||||
await update;
|
||||
|
||||
expect(deps.removeTask).toHaveBeenCalledWith("t-1");
|
||||
expect(deps.patchTask).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("newer full refresh invalidates a delayed task update", async () => {
|
||||
const deps = createMockDeps();
|
||||
const eventGuard = createSnapshotEventGuard();
|
||||
const taskFetch = deferred<{ ok: boolean; data: { id: string; title: string } }>();
|
||||
deps.getTask.mockReturnValue(taskFetch.promise);
|
||||
|
||||
const update = handleSnapshotEntityEvent(
|
||||
{ type: "task-updated", entityId: "t-1", action: "updated" },
|
||||
deps,
|
||||
eventGuard,
|
||||
);
|
||||
eventGuard.invalidateSnapshot();
|
||||
taskFetch.resolve({ ok: true, data: { id: "t-1", title: "Stale task" } });
|
||||
await update;
|
||||
|
||||
expect(deps.patchTask).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("later task update wins over an earlier delayed task update", async () => {
|
||||
const deps = createMockDeps();
|
||||
const eventGuard = createSnapshotEventGuard();
|
||||
const firstFetch = deferred<{ ok: boolean; data: { id: string; title: string } }>();
|
||||
const secondFetch = deferred<{ ok: boolean; data: { id: string; title: string } }>();
|
||||
deps.getTask
|
||||
.mockReturnValueOnce(firstFetch.promise)
|
||||
.mockReturnValueOnce(secondFetch.promise);
|
||||
|
||||
const firstUpdate = handleSnapshotEntityEvent(
|
||||
{ type: "task-updated", entityId: "t-1", action: "updated" },
|
||||
deps,
|
||||
eventGuard,
|
||||
);
|
||||
const secondUpdate = handleSnapshotEntityEvent(
|
||||
{ type: "task-updated", entityId: "t-1", action: "updated" },
|
||||
deps,
|
||||
eventGuard,
|
||||
);
|
||||
|
||||
secondFetch.resolve({ ok: true, data: { id: "t-1", title: "Fresh task" } });
|
||||
await secondUpdate;
|
||||
firstFetch.resolve({ ok: true, data: { id: "t-1", title: "Stale task" } });
|
||||
await firstUpdate;
|
||||
|
||||
expect(deps.patchTask).toHaveBeenCalledTimes(1);
|
||||
expect(deps.patchTask).toHaveBeenCalledWith({ id: "t-1", title: "Fresh task" });
|
||||
});
|
||||
|
||||
it("run-updated completed triggers full refresh", async () => {
|
||||
const deps = createMockDeps();
|
||||
await handleSnapshotEntityEvent(
|
||||
{ type: "run-updated", entityId: "r-1", action: "completed" },
|
||||
deps,
|
||||
);
|
||||
expect(deps.fullRefresh).toHaveBeenCalled();
|
||||
expect(deps.getRun).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("failed getTask fetch removes the missing task", async () => {
|
||||
const deps = createMockDeps();
|
||||
deps.getTask.mockResolvedValue({ ok: false, error: { code: "NOT_FOUND", message: "missing" } });
|
||||
await handleSnapshotEntityEvent(
|
||||
{ type: "task-updated", entityId: "t-1", action: "updated" },
|
||||
deps,
|
||||
);
|
||||
expect(deps.removeTask).toHaveBeenCalledWith("t-1");
|
||||
expect(deps.patchTask).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("approval-updated event calls getApproval and patchApproval", async () => {
|
||||
const deps = createMockDeps();
|
||||
await handleSnapshotEntityEvent(
|
||||
{ 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 handleSnapshotEntityEvent(
|
||||
{ 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 handleSnapshotEntityEvent(
|
||||
{ type: "budget-changed", entityId: "c-1", action: "changed" },
|
||||
deps,
|
||||
);
|
||||
expect(deps.fullRefresh).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("missing companyId triggers full refresh", async () => {
|
||||
const deps = createMockDeps();
|
||||
deps.companyId = null;
|
||||
await handleSnapshotEntityEvent(
|
||||
{ type: "task-updated", entityId: "t-1", action: "updated" },
|
||||
deps,
|
||||
);
|
||||
expect(deps.fullRefresh).toHaveBeenCalled();
|
||||
expect(deps.getTask).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("IPC error triggers full refresh", async () => {
|
||||
const deps = createMockDeps();
|
||||
deps.getTask.mockRejectedValue(new Error("IPC failed"));
|
||||
await handleSnapshotEntityEvent(
|
||||
{ type: "task-updated", entityId: "t-1", action: "updated" },
|
||||
deps,
|
||||
);
|
||||
expect(deps.fullRefresh).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unknown entity event triggers full refresh", async () => {
|
||||
const deps = createMockDeps();
|
||||
await handleSnapshotEntityEvent(
|
||||
{ type: "future-entity", entityId: "x-1", action: "created" } as unknown as GranularEventEntry,
|
||||
deps,
|
||||
);
|
||||
expect(deps.fullRefresh).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("run-updated status change fetches and patches run", async () => {
|
||||
const deps = createMockDeps();
|
||||
await handleSnapshotEntityEvent(
|
||||
{ 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).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
+12
-283
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { useAppStore } from "@renderer/state/store";
|
||||
import type { ProfileSnapshot, TaskRecord, RunRecord, AgentRecord, ApprovalRecord } from "@shared/types";
|
||||
import { assembleProfileSnapshot } from "@renderer/state/snapshot-sections";
|
||||
import type { ProfileSnapshot } from "@shared/types";
|
||||
|
||||
// Mock window.agentCompany for refreshSnapshot tests. Sidebar badges
|
||||
// no longer traverse IPC — they are derived from the snapshot in
|
||||
@@ -31,6 +32,7 @@ describe("useAppStore", () => {
|
||||
|
||||
// CompanySlice
|
||||
expect(state).toHaveProperty("snapshot");
|
||||
expect(state).toHaveProperty("snapshotSections");
|
||||
expect(state).toHaveProperty("setSnapshot");
|
||||
expect(state).toHaveProperty("refreshSnapshot");
|
||||
|
||||
@@ -149,6 +151,10 @@ describe("useAppStore", () => {
|
||||
useAppStore.getState().setSnapshot(mockSnapshot);
|
||||
|
||||
expect(useAppStore.getState().snapshot).toEqual(mockSnapshot);
|
||||
expect(useAppStore.getState().snapshotSections?.metadata.profilePath).toBe("/test/path");
|
||||
expect(useAppStore.getState().snapshotSections?.catalog.companies).toEqual([]);
|
||||
expect(useAppStore.getState().snapshotSections?.company.tasks).toEqual([]);
|
||||
expectSnapshotSectionsMatchSnapshot();
|
||||
});
|
||||
|
||||
it("CompanySlice.refreshSnapshot() calls window.agentCompany.loadSnapshot()", async () => {
|
||||
@@ -189,6 +195,8 @@ describe("useAppStore", () => {
|
||||
|
||||
expect(mockLoadSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(useAppStore.getState().snapshot).toEqual(mockSnapshot);
|
||||
expect(useAppStore.getState().snapshotSections?.metadata.currentCompanyId).toBe("company-1");
|
||||
expectSnapshotSectionsMatchSnapshot();
|
||||
});
|
||||
|
||||
it("UiSlice.setLocale('zh') updates locale to 'zh'", () => {
|
||||
@@ -224,286 +232,7 @@ describe("useAppStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -- Minimal snapshot fixture for patch tests --
|
||||
function makeSnapshot(overrides: Partial<ProfileSnapshot> = {}): ProfileSnapshot {
|
||||
return {
|
||||
currentCompanyId: "company-1",
|
||||
profilePath: "/test/path",
|
||||
updatesEnabled: true,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
backend: { secretBackend: "safe_storage" },
|
||||
companies: [],
|
||||
connectors: [],
|
||||
workspaces: [],
|
||||
agents: [],
|
||||
goals: [],
|
||||
projects: [],
|
||||
tasks: [],
|
||||
approvals: [],
|
||||
runs: [],
|
||||
costs: [],
|
||||
activity: [],
|
||||
secrets: [],
|
||||
comments: [],
|
||||
socialAccounts: [],
|
||||
meetings: [],
|
||||
documents: [],
|
||||
workProducts: [],
|
||||
knowledgeBase: [],
|
||||
sprints: [],
|
||||
automationRules: [],
|
||||
agentMessages: [],
|
||||
workflows: [],
|
||||
...overrides,
|
||||
};
|
||||
function expectSnapshotSectionsMatchSnapshot() {
|
||||
const { snapshot, snapshotSections } = useAppStore.getState();
|
||||
expect(snapshot).toEqual(snapshotSections ? assembleProfileSnapshot(snapshotSections) : null);
|
||||
}
|
||||
|
||||
const fakeTask = (id: string, title = "Task"): TaskRecord => ({
|
||||
id,
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
title,
|
||||
description: "",
|
||||
assigneeAgentId: null,
|
||||
workspaceId: null,
|
||||
status: "open",
|
||||
priority: "medium",
|
||||
activeRunId: null,
|
||||
sessionJson: null,
|
||||
requiresUserReview: false,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
} as unknown as TaskRecord);
|
||||
|
||||
const fakeRun = (id: string): RunRecord => ({
|
||||
id,
|
||||
companyId: "company-1",
|
||||
taskId: "t-1",
|
||||
agentId: "ag-1",
|
||||
workspaceId: null,
|
||||
connectorId: "claude-pro",
|
||||
status: "queued",
|
||||
summary: "",
|
||||
errorMessage: null,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
model: null,
|
||||
unattributedCost: false,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
logPath: null,
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
} as RunRecord);
|
||||
|
||||
const fakeAgent = (id: string, name = "Agent"): AgentRecord => ({
|
||||
id,
|
||||
companyId: "company-1",
|
||||
name,
|
||||
role: "engineer",
|
||||
title: "",
|
||||
department: "engineering",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "claude-pro",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "",
|
||||
budgetMonthlyUsd: 100,
|
||||
spentMonthlyUsd: 0,
|
||||
metadataJson: "{}",
|
||||
heartbeatEnabled: false,
|
||||
heartbeatIntervalSec: 0,
|
||||
lastHeartbeatAt: null,
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
} as AgentRecord);
|
||||
|
||||
const fakeApproval = (id: string): ApprovalRecord => ({
|
||||
id,
|
||||
companyId: "company-1",
|
||||
relatedTaskId: "t-1",
|
||||
relatedAgentId: null,
|
||||
requestedByAgentId: "ag-1",
|
||||
type: "task_completion",
|
||||
payloadSummary: "",
|
||||
impactSummary: "",
|
||||
payloadJson: null,
|
||||
decisionNote: null,
|
||||
state: "pending",
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
} as unknown as ApprovalRecord);
|
||||
|
||||
describe("CompanySlice patch methods", () => {
|
||||
beforeEach(() => {
|
||||
useAppStore.setState(useAppStore.getInitialState());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// -- patchTask --
|
||||
it("patchTask inserts new task at beginning when task ID is not found", () => {
|
||||
const snapshot = makeSnapshot({ tasks: [fakeTask("t-existing")] });
|
||||
useAppStore.getState().setSnapshot(snapshot);
|
||||
|
||||
const newTask = fakeTask("t-new", "New Task");
|
||||
useAppStore.getState().patchTask(newTask);
|
||||
|
||||
const tasks = useAppStore.getState().snapshot!.tasks;
|
||||
expect(tasks).toHaveLength(2);
|
||||
expect(tasks[0]!.id).toBe("t-new");
|
||||
expect(tasks[1]!.id).toBe("t-existing");
|
||||
});
|
||||
|
||||
it("patchTask updates existing task in-place when task ID matches", () => {
|
||||
const snapshot = makeSnapshot({ tasks: [fakeTask("t-1", "Original")] });
|
||||
useAppStore.getState().setSnapshot(snapshot);
|
||||
|
||||
const updated = fakeTask("t-1", "Updated");
|
||||
useAppStore.getState().patchTask(updated);
|
||||
|
||||
const tasks = useAppStore.getState().snapshot!.tasks;
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0]!.title).toBe("Updated");
|
||||
});
|
||||
|
||||
it("removeTask filters out task by ID", () => {
|
||||
const snapshot = makeSnapshot({ tasks: [fakeTask("t-1"), fakeTask("t-2")] });
|
||||
useAppStore.getState().setSnapshot(snapshot);
|
||||
|
||||
useAppStore.getState().removeTask("t-1");
|
||||
|
||||
const tasks = useAppStore.getState().snapshot!.tasks;
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0]!.id).toBe("t-2");
|
||||
});
|
||||
|
||||
// -- patchRun --
|
||||
it("patchRun inserts new run when run ID is not found", () => {
|
||||
const snapshot = makeSnapshot({ runs: [fakeRun("r-existing")] });
|
||||
useAppStore.getState().setSnapshot(snapshot);
|
||||
|
||||
useAppStore.getState().patchRun(fakeRun("r-new"));
|
||||
|
||||
const runs = useAppStore.getState().snapshot!.runs;
|
||||
expect(runs).toHaveLength(2);
|
||||
expect(runs[0]!.id).toBe("r-new");
|
||||
});
|
||||
|
||||
it("patchRun updates existing run in-place", () => {
|
||||
const run = fakeRun("r-1");
|
||||
const snapshot = makeSnapshot({ runs: [run] });
|
||||
useAppStore.getState().setSnapshot(snapshot);
|
||||
|
||||
const updated = { ...fakeRun("r-1"), status: "running" as const };
|
||||
useAppStore.getState().patchRun(updated);
|
||||
|
||||
const runs = useAppStore.getState().snapshot!.runs;
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]!.status).toBe("running");
|
||||
});
|
||||
|
||||
it("removeRun filters out run by ID", () => {
|
||||
const snapshot = makeSnapshot({ runs: [fakeRun("r-1"), fakeRun("r-2")] });
|
||||
useAppStore.getState().setSnapshot(snapshot);
|
||||
|
||||
useAppStore.getState().removeRun("r-1");
|
||||
|
||||
const runs = useAppStore.getState().snapshot!.runs;
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]!.id).toBe("r-2");
|
||||
});
|
||||
|
||||
// -- patchAgent --
|
||||
it("patchAgent inserts new agent when agent ID is not found", () => {
|
||||
const snapshot = makeSnapshot({ agents: [fakeAgent("ag-existing")] });
|
||||
useAppStore.getState().setSnapshot(snapshot);
|
||||
|
||||
useAppStore.getState().patchAgent(fakeAgent("ag-new", "New Agent"));
|
||||
|
||||
const agents = useAppStore.getState().snapshot!.agents;
|
||||
expect(agents).toHaveLength(2);
|
||||
expect(agents[0]!.id).toBe("ag-new");
|
||||
});
|
||||
|
||||
it("patchAgent updates existing agent in-place", () => {
|
||||
const snapshot = makeSnapshot({ agents: [fakeAgent("ag-1", "Old")] });
|
||||
useAppStore.getState().setSnapshot(snapshot);
|
||||
|
||||
useAppStore.getState().patchAgent(fakeAgent("ag-1", "New"));
|
||||
|
||||
const agents = useAppStore.getState().snapshot!.agents;
|
||||
expect(agents).toHaveLength(1);
|
||||
expect(agents[0]!.name).toBe("New");
|
||||
});
|
||||
|
||||
it("removeAgent filters out agent by ID", () => {
|
||||
const snapshot = makeSnapshot({ agents: [fakeAgent("ag-1"), fakeAgent("ag-2")] });
|
||||
useAppStore.getState().setSnapshot(snapshot);
|
||||
|
||||
useAppStore.getState().removeAgent("ag-1");
|
||||
|
||||
const agents = useAppStore.getState().snapshot!.agents;
|
||||
expect(agents).toHaveLength(1);
|
||||
expect(agents[0]!.id).toBe("ag-2");
|
||||
});
|
||||
|
||||
// -- patchApproval --
|
||||
it("patchApproval inserts new approval when approval ID is not found", () => {
|
||||
const snapshot = makeSnapshot({ approvals: [fakeApproval("ap-existing")] });
|
||||
useAppStore.getState().setSnapshot(snapshot);
|
||||
|
||||
useAppStore.getState().patchApproval(fakeApproval("ap-new"));
|
||||
|
||||
const approvals = useAppStore.getState().snapshot!.approvals;
|
||||
expect(approvals).toHaveLength(2);
|
||||
expect(approvals[0]!.id).toBe("ap-new");
|
||||
});
|
||||
|
||||
it("patchApproval updates existing approval in-place", () => {
|
||||
const approval = fakeApproval("ap-1");
|
||||
const snapshot = makeSnapshot({ approvals: [approval] });
|
||||
useAppStore.getState().setSnapshot(snapshot);
|
||||
|
||||
const updated = { ...fakeApproval("ap-1"), state: "approved" as const };
|
||||
useAppStore.getState().patchApproval(updated);
|
||||
|
||||
const approvals = useAppStore.getState().snapshot!.approvals;
|
||||
expect(approvals).toHaveLength(1);
|
||||
expect(approvals[0]!.state).toBe("approved");
|
||||
});
|
||||
|
||||
it("removeApproval filters out approval by ID", () => {
|
||||
const snapshot = makeSnapshot({ approvals: [fakeApproval("ap-1"), fakeApproval("ap-2")] });
|
||||
useAppStore.getState().setSnapshot(snapshot);
|
||||
|
||||
useAppStore.getState().removeApproval("ap-1");
|
||||
|
||||
const approvals = useAppStore.getState().snapshot!.approvals;
|
||||
expect(approvals).toHaveLength(1);
|
||||
expect(approvals[0]!.id).toBe("ap-2");
|
||||
});
|
||||
|
||||
// -- null-snapshot safety --
|
||||
it("patch methods are no-ops when snapshot is null", () => {
|
||||
expect(useAppStore.getState().snapshot).toBeNull();
|
||||
|
||||
// These should not crash
|
||||
useAppStore.getState().patchTask(fakeTask("t-1"));
|
||||
useAppStore.getState().removeTask("t-1");
|
||||
useAppStore.getState().patchRun(fakeRun("r-1"));
|
||||
useAppStore.getState().removeRun("r-1");
|
||||
useAppStore.getState().patchAgent(fakeAgent("ag-1"));
|
||||
useAppStore.getState().removeAgent("ag-1");
|
||||
useAppStore.getState().patchApproval(fakeApproval("ap-1"));
|
||||
useAppStore.getState().removeApproval("ap-1");
|
||||
|
||||
expect(useAppStore.getState().snapshot).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user