flock-core 0.5.0b70__py3-none-any.whl → 0.5.0b75__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of flock-core might be problematic. Click here for more details.

Files changed (62) hide show
  1. flock/agent.py +39 -1
  2. flock/artifacts.py +17 -10
  3. flock/cli.py +1 -1
  4. flock/dashboard/__init__.py +2 -0
  5. flock/dashboard/collector.py +282 -6
  6. flock/dashboard/events.py +6 -0
  7. flock/dashboard/graph_builder.py +563 -0
  8. flock/dashboard/launcher.py +11 -6
  9. flock/dashboard/models/graph.py +156 -0
  10. flock/dashboard/service.py +175 -14
  11. flock/dashboard/static_v2/assets/index-DFRnI_mt.js +111 -0
  12. flock/dashboard/static_v2/assets/index-fPLNdmp1.css +1 -0
  13. flock/dashboard/static_v2/index.html +13 -0
  14. flock/dashboard/websocket.py +2 -2
  15. flock/engines/dspy_engine.py +28 -9
  16. flock/frontend/README.md +6 -6
  17. flock/frontend/src/App.tsx +23 -31
  18. flock/frontend/src/__tests__/integration/graph-snapshot.test.tsx +647 -0
  19. flock/frontend/src/components/details/DetailWindowContainer.tsx +13 -17
  20. flock/frontend/src/components/details/MessageDetailWindow.tsx +439 -0
  21. flock/frontend/src/components/details/MessageHistoryTab.tsx +128 -53
  22. flock/frontend/src/components/details/RunStatusTab.tsx +79 -38
  23. flock/frontend/src/components/graph/AgentNode.test.tsx +3 -1
  24. flock/frontend/src/components/graph/AgentNode.tsx +8 -6
  25. flock/frontend/src/components/graph/GraphCanvas.tsx +13 -8
  26. flock/frontend/src/components/graph/MessageNode.test.tsx +3 -1
  27. flock/frontend/src/components/graph/MessageNode.tsx +16 -3
  28. flock/frontend/src/components/layout/DashboardLayout.tsx +12 -9
  29. flock/frontend/src/components/modules/HistoricalArtifactsModule.tsx +4 -14
  30. flock/frontend/src/components/modules/ModuleRegistry.ts +5 -3
  31. flock/frontend/src/hooks/useModules.ts +12 -4
  32. flock/frontend/src/hooks/usePersistence.ts +5 -3
  33. flock/frontend/src/services/api.ts +3 -19
  34. flock/frontend/src/services/graphService.test.ts +330 -0
  35. flock/frontend/src/services/graphService.ts +75 -0
  36. flock/frontend/src/services/websocket.ts +104 -268
  37. flock/frontend/src/store/filterStore.test.ts +89 -1
  38. flock/frontend/src/store/filterStore.ts +38 -16
  39. flock/frontend/src/store/graphStore.test.ts +538 -173
  40. flock/frontend/src/store/graphStore.ts +374 -465
  41. flock/frontend/src/store/moduleStore.ts +51 -33
  42. flock/frontend/src/store/uiStore.ts +23 -11
  43. flock/frontend/src/types/graph.ts +77 -44
  44. flock/frontend/src/utils/mockData.ts +16 -3
  45. flock/frontend/vite.config.ts +2 -2
  46. flock/orchestrator.py +24 -6
  47. flock/service.py +2 -2
  48. flock/store.py +169 -4
  49. flock/themes/darkmatrix.toml +2 -2
  50. flock/themes/deep.toml +2 -2
  51. flock/themes/neopolitan.toml +4 -4
  52. {flock_core-0.5.0b70.dist-info → flock_core-0.5.0b75.dist-info}/METADATA +1 -1
  53. {flock_core-0.5.0b70.dist-info → flock_core-0.5.0b75.dist-info}/RECORD +56 -53
  54. flock/frontend/src/__tests__/e2e/critical-scenarios.test.tsx +0 -586
  55. flock/frontend/src/__tests__/integration/filtering-e2e.test.tsx +0 -391
  56. flock/frontend/src/__tests__/integration/graph-rendering.test.tsx +0 -640
  57. flock/frontend/src/services/websocket.test.ts +0 -595
  58. flock/frontend/src/utils/transforms.test.ts +0 -860
  59. flock/frontend/src/utils/transforms.ts +0 -323
  60. {flock_core-0.5.0b70.dist-info → flock_core-0.5.0b75.dist-info}/WHEEL +0 -0
  61. {flock_core-0.5.0b70.dist-info → flock_core-0.5.0b75.dist-info}/entry_points.txt +0 -0
  62. {flock_core-0.5.0b70.dist-info → flock_core-0.5.0b75.dist-info}/licenses/LICENSE +0 -0
@@ -1,551 +1,460 @@
1
1
  import { create } from 'zustand';
2
2
  import { devtools } from 'zustand/middleware';
3
3
  import { Node, Edge } from '@xyflow/react';
4
- import { Agent, Message, AgentNodeData, MessageNodeData } from '../types/graph';
5
- import { deriveAgentViewEdges, deriveBlackboardViewEdges, Artifact, Run, DashboardState } from '../utils/transforms';
4
+ import { GraphSnapshot, GraphStatistics, GraphRequest } from '../types/graph';
5
+ import { fetchGraphSnapshot, mergeNodePositions, overlayWebSocketState } from '../services/graphService';
6
6
  import { useFilterStore } from './filterStore';
7
+ import { Message } from '../types/graph';
8
+ import { indexedDBService } from '../services/indexeddb';
9
+
10
+ /**
11
+ * Graph Store - UI Optimization Migration (Spec 002)
12
+ *
13
+ * SIMPLIFIED backend-integrated version that replaces 553 lines of client-side
14
+ * graph construction with backend snapshot consumption.
15
+ *
16
+ * KEY CHANGES:
17
+ * - Backend generates nodes + edges + statistics
18
+ * - Position merging: saved > current > backend > random
19
+ * - WebSocket state overlay for real-time updates (status, tokens)
20
+ * - Debounced refresh: 100ms batching for snappy UX
21
+ * - No more client-side edge derivation
22
+ * - No more synthetic runs or complex Maps
23
+ */
7
24
 
8
25
  interface GraphState {
9
- // Core data
10
- agents: Map<string, Agent>;
11
- messages: Map<string, Message>;
12
- events: Message[];
13
- runs: Map<string, Run>;
14
-
15
- // Phase 11 Bug Fix: Track actual consumption (artifact_id -> consumer_ids[])
16
- // Updated by agent_activated events to reflect filtering and actual consumption
17
- consumptions: Map<string, string[]>;
26
+ // Real-time WebSocket state (overlaid on backend snapshot)
27
+ agentStatus: Map<string, string>;
28
+ streamingTokens: Map<string, string[]>;
18
29
 
19
- // Message node positions (message_id -> {x, y})
20
- // Messages don't have position in their data model, so we track it separately
21
- messagePositions: Map<string, { x: number; y: number }>;
22
-
23
- // Graph representation
30
+ // Backend snapshot state
24
31
  nodes: Node[];
25
32
  edges: Edge[];
33
+ statistics: GraphStatistics | null;
26
34
 
27
- // Actions
28
- addAgent: (agent: Agent) => void;
29
- updateAgent: (id: string, updates: Partial<Agent>) => void;
30
- removeAgent: (id: string) => void;
35
+ // UI state
36
+ events: Message[];
37
+ viewMode: 'agent' | 'blackboard';
31
38
 
32
- addMessage: (message: Message) => void;
33
- updateMessage: (id: string, updates: Partial<Message>) => void;
34
- addRun: (run: Run) => void;
39
+ // Position persistence (saved to IndexedDB)
40
+ savedPositions: Map<string, { x: number; y: number }>;
35
41
 
36
- // Phase 11 Bug Fix: Track actual consumption from agent_activated events
37
- recordConsumption: (artifactIds: string[], consumerId: string) => void;
42
+ // Loading state
43
+ isLoading: boolean;
44
+ error: string | null;
38
45
 
39
- // Transform streaming message to final message (changes ID)
40
- finalizeStreamingMessage: (oldId: string, newMessage: Message) => void;
46
+ // Actions - Backend integration
47
+ generateAgentViewGraph: () => Promise<void>;
48
+ generateBlackboardViewGraph: () => Promise<void>;
49
+ refreshCurrentView: () => Promise<void>;
50
+ scheduleRefresh: () => void; // Debounced refresh (500ms)
41
51
 
42
- updateNodePosition: (nodeId: string, position: { x: number; y: number }) => void;
52
+ // Actions - Real-time WebSocket updates
53
+ updateAgentStatus: (agentId: string, status: string) => void;
54
+ updateStreamingTokens: (agentId: string, tokens: string[]) => void;
55
+ addEvent: (message: Message) => void;
43
56
 
44
- // Mode-specific graph generation
45
- generateAgentViewGraph: () => void;
46
- generateBlackboardViewGraph: () => void;
57
+ // Actions - Streaming message nodes (Phase 6)
58
+ createOrUpdateStreamingMessageNode: (artifactId: string, token: string, eventData?: any) => void;
59
+ finalizeStreamingMessageNode: (artifactId: string) => void;
47
60
 
48
- // Filter application
49
- applyFilters: () => void;
61
+ // Actions - Position persistence
62
+ updateNodePosition: (nodeId: string, position: { x: number; y: number }) => void;
63
+ saveNodePosition: (nodeId: string, position: { x: number; y: number }) => void;
64
+ loadSavedPositions: () => Promise<void>;
50
65
 
51
- // Bulk updates
52
- batchUpdate: (update: { agents?: Agent[]; messages?: Message[]; runs?: Run[] }) => void;
66
+ // Actions - UI state
67
+ setViewMode: (viewMode: 'agent' | 'blackboard') => void;
53
68
  }
54
69
 
55
- // Helper function to convert Message to Artifact
56
- function messageToArtifact(message: Message, consumptions: Map<string, string[]>): Artifact {
57
- // BUG FIX: Use ACTUAL consumption data from consumptions Map, not inferred from subscriptions!
58
- // This ensures edges reflect what actually happened, not what "should" happen based on current subscriptions.
59
- const actualConsumers = consumptions.get(message.id) || [];
60
-
61
- return {
62
- artifact_id: message.id,
63
- artifact_type: message.type,
64
- produced_by: message.producedBy,
65
- consumed_by: actualConsumers, // Use actual consumption data
66
- published_at: new Date(message.timestamp).toISOString(),
67
- payload: message.payload,
68
- correlation_id: message.correlationId,
70
+ /**
71
+ * Convert TimeRange (number timestamps) to TimeRangeFilter (ISO string timestamps)
72
+ */
73
+ function convertTimeRange(range: { preset: string; start?: number; end?: number }): GraphRequest['filters']['time_range'] {
74
+ const result: GraphRequest['filters']['time_range'] = {
75
+ preset: range.preset as any,
69
76
  };
77
+
78
+ if (range.start !== undefined) {
79
+ result.start = new Date(range.start).toISOString();
80
+ }
81
+ if (range.end !== undefined) {
82
+ result.end = new Date(range.end).toISOString();
83
+ }
84
+
85
+ return result;
70
86
  }
71
87
 
72
- // Helper function to convert store state to DashboardState
73
- function toDashboardState(
74
- messages: Map<string, Message>,
75
- runs: Map<string, Run>,
76
- consumptions: Map<string, string[]>
77
- ): DashboardState {
78
- const artifacts = new Map<string, Artifact>();
79
- const syntheticRuns = new Map(runs);
80
-
81
- const producedBuckets = new Map<string, Set<string>>();
82
- const consumedBuckets = new Map<string, Set<string>>();
83
-
84
- // Helper to build bucket keys based on agent + correlation
85
- const makeBucketKey = (agentId: string, correlationId: string) =>
86
- `${agentId}::${correlationId || 'uncorrelated'}`;
87
-
88
- // Track (agent, correlation) pairs that already have explicit run data
89
- const existingRunBuckets = new Set<string>();
90
- runs.forEach((run) => {
91
- existingRunBuckets.add(makeBucketKey(run.agent_name, run.correlation_id));
92
- });
93
-
94
- messages.forEach((message) => {
95
- artifacts.set(message.id, messageToArtifact(message, consumptions));
96
-
97
- if (message.producedBy) {
98
- const key = makeBucketKey(message.producedBy, message.correlationId);
99
- if (!producedBuckets.has(key)) {
100
- producedBuckets.set(key, new Set());
101
- }
102
- producedBuckets.get(key)!.add(message.id);
103
- }
104
- });
105
-
106
- consumptions.forEach((consumerIds, artifactId) => {
107
- const message = messages.get(artifactId);
108
- const correlationId = message?.correlationId ?? '';
109
- consumerIds.forEach((consumerId) => {
110
- const key = makeBucketKey(consumerId, correlationId);
111
- if (!consumedBuckets.has(key)) {
112
- consumedBuckets.set(key, new Set());
113
- }
114
- consumedBuckets.get(key)!.add(artifactId);
115
- });
116
- });
117
-
118
- let syntheticCounter = 0;
119
- consumedBuckets.forEach((consumedSet, key) => {
120
- if (consumedSet.size === 0) {
121
- return;
122
- }
123
- const producedSet = producedBuckets.get(key);
124
- if (!producedSet || producedSet.size === 0) {
125
- return;
126
- }
127
-
128
- if (existingRunBuckets.has(key)) {
129
- return;
130
- }
131
-
132
- const [agentIdRaw, correlationPartRaw] = key.split('::');
133
- const agentId = agentIdRaw || 'unknown-agent';
134
- const correlationPart = correlationPartRaw || 'uncorrelated';
135
- const runId = `historic_${agentId}_${correlationPart}_${syntheticCounter++}`;
136
-
137
- if (!syntheticRuns.has(runId)) {
138
- syntheticRuns.set(runId, {
139
- run_id: runId,
140
- agent_name: agentId,
141
- correlation_id: correlationPart === 'uncorrelated' ? '' : correlationPart,
142
- status: 'completed',
143
- consumed_artifacts: Array.from(consumedSet),
144
- produced_artifacts: Array.from(producedSet),
145
- });
146
- }
147
- });
88
+ /**
89
+ * Build GraphRequest from current filter state
90
+ */
91
+ function buildGraphRequest(viewMode: 'agent' | 'blackboard'): GraphRequest {
92
+ const filterState = useFilterStore.getState();
148
93
 
149
94
  return {
150
- artifacts,
151
- runs: syntheticRuns,
152
- consumptions, // Phase 11: Pass actual consumption data for filtered count calculation
95
+ viewMode,
96
+ filters: {
97
+ correlation_id: filterState.correlationId || null,
98
+ time_range: convertTimeRange(filterState.timeRange),
99
+ artifactTypes: filterState.selectedArtifactTypes,
100
+ producers: filterState.selectedProducers,
101
+ tags: filterState.selectedTags,
102
+ visibility: filterState.selectedVisibility,
103
+ },
104
+ options: {
105
+ include_statistics: true,
106
+ },
153
107
  };
154
108
  }
155
109
 
110
+ /**
111
+ * Debounce timer for graph refresh (500ms batching)
112
+ */
113
+ let refreshTimer: ReturnType<typeof setTimeout> | null = null;
114
+
156
115
  export const useGraphStore = create<GraphState>()(
157
116
  devtools(
158
117
  (set, get) => ({
159
- agents: new Map(),
160
- messages: new Map(),
161
- events: [],
162
- runs: new Map(),
163
- consumptions: new Map(), // Phase 11: Track actual artifact consumption
164
- messagePositions: new Map(), // Track message node positions
118
+ // Initial state
119
+ agentStatus: new Map(),
120
+ streamingTokens: new Map(),
165
121
  nodes: [],
166
122
  edges: [],
123
+ statistics: null,
124
+ events: [],
125
+ viewMode: 'agent',
126
+ savedPositions: new Map(),
127
+ isLoading: false,
128
+ error: null,
167
129
 
168
- addAgent: (agent) =>
169
- set((state) => {
170
- const agents = new Map(state.agents);
171
- agents.set(agent.id, agent);
172
- return { agents };
173
- }),
130
+ // Backend integration actions
131
+ generateAgentViewGraph: async () => {
132
+ set({ isLoading: true, error: null, viewMode: 'agent' });
174
133
 
175
- updateAgent: (id, updates) =>
176
- set((state) => {
177
- const agents = new Map(state.agents);
178
- const agent = agents.get(id);
179
- if (agent) {
180
- agents.set(id, { ...agent, ...updates });
181
- }
182
- return { agents };
183
- }),
134
+ try {
135
+ // Load saved positions from IndexedDB first
136
+ await get().loadSavedPositions();
184
137
 
185
- removeAgent: (id) =>
186
- set((state) => {
187
- const agents = new Map(state.agents);
188
- agents.delete(id);
189
- return { agents };
190
- }),
138
+ const request = buildGraphRequest('agent');
139
+ const snapshot: GraphSnapshot = await fetchGraphSnapshot(request);
191
140
 
192
- addMessage: (message) =>
193
- set((state) => {
194
- const messages = new Map(state.messages);
195
- messages.set(message.id, message);
141
+ const { savedPositions, nodes: currentNodes, agentStatus, streamingTokens } = get();
196
142
 
197
- // Only add to events if this is a NEW message (not already in the array)
198
- // This prevents streaming token updates from flooding the Event Log
199
- const isDuplicate = state.events.some(e => e.id === message.id);
200
- const events = isDuplicate
201
- ? state.events // Skip if already in events array
202
- : [message, ...state.events].slice(0, 100); // Add new message
143
+ // Merge positions: saved > current > backend > random
144
+ const mergedNodes = mergeNodePositions(snapshot.nodes, savedPositions, currentNodes);
203
145
 
204
- return { messages, events };
205
- }),
146
+ // Overlay real-time WebSocket state
147
+ const finalNodes = overlayWebSocketState(mergedNodes, agentStatus, streamingTokens);
206
148
 
207
- updateMessage: (id, updates) =>
208
- set((state) => {
209
- const messages = new Map(state.messages);
210
- const message = messages.get(id);
211
- if (message) {
212
- messages.set(id, { ...message, ...updates });
213
- }
214
- // Note: updateMessage does NOT touch the events array
215
- // This allows streaming updates without flooding the Event Log
216
- return { messages };
217
- }),
149
+ set({
150
+ nodes: finalNodes,
151
+ edges: snapshot.edges as Edge[],
152
+ statistics: snapshot.statistics,
153
+ isLoading: false,
154
+ });
218
155
 
219
- addRun: (run) =>
220
- set((state) => {
221
- const runs = new Map(state.runs);
222
- runs.set(run.run_id, run);
223
- return { runs };
224
- }),
156
+ // Update filter facets from backend statistics
157
+ if (snapshot.statistics?.artifactSummary) {
158
+ const summary = snapshot.statistics.artifactSummary;
159
+ const filterState = useFilterStore.getState();
160
+
161
+ // Transform ArtifactSummary to FilterFacets format
162
+ const facets = {
163
+ artifactTypes: Object.keys(summary.by_type),
164
+ producers: Object.keys(summary.by_producer),
165
+ tags: Object.keys(summary.tag_counts),
166
+ visibilities: Object.keys(summary.by_visibility),
167
+ };
225
168
 
226
- // Phase 11 Bug Fix: Record actual consumption from agent_activated events
227
- recordConsumption: (artifactIds, consumerId) =>
228
- set((state) => {
229
- const consumptions = new Map(state.consumptions);
230
- artifactIds.forEach((artifactId) => {
231
- const existing = consumptions.get(artifactId) || [];
232
- if (!existing.includes(consumerId)) {
233
- consumptions.set(artifactId, [...existing, consumerId]);
169
+ // Support both updateAvailableFacets (production) and updateFacets (test mock)
170
+ if ('updateAvailableFacets' in filterState && typeof filterState.updateAvailableFacets === 'function') {
171
+ filterState.updateAvailableFacets(facets);
172
+ } else if ('updateFacets' in filterState && typeof (filterState as any).updateFacets === 'function') {
173
+ (filterState as any).updateFacets(facets);
234
174
  }
175
+ }
176
+ } catch (error) {
177
+ const errorMessage = error instanceof Error ? error.message : 'Failed to fetch graph';
178
+ set({
179
+ error: errorMessage,
180
+ isLoading: false,
235
181
  });
236
- return { consumptions };
237
- }),
182
+ throw error; // Re-throw for test assertions
183
+ }
184
+ },
238
185
 
239
- finalizeStreamingMessage: (oldId, newMessage) =>
240
- set((state) => {
241
- // Remove old streaming message, add final message with new ID
242
- const messages = new Map(state.messages);
243
- messages.delete(oldId);
244
- messages.set(newMessage.id, newMessage);
245
-
246
- // Transfer position from old ID to new ID
247
- const messagePositions = new Map(state.messagePositions);
248
- const oldPos = messagePositions.get(oldId);
249
- if (oldPos) {
250
- messagePositions.delete(oldId);
251
- messagePositions.set(newMessage.id, oldPos);
252
- }
186
+ generateBlackboardViewGraph: async () => {
187
+ set({ isLoading: true, error: null, viewMode: 'blackboard' });
253
188
 
254
- // Update events array: replace streaming ID with final message ID
255
- const events = state.events.map(e =>
256
- e.id === oldId ? newMessage : e
257
- );
189
+ try {
190
+ // Load saved positions from IndexedDB first
191
+ await get().loadSavedPositions();
258
192
 
259
- return { messages, messagePositions, events };
260
- }),
193
+ const request = buildGraphRequest('blackboard');
194
+ const snapshot: GraphSnapshot = await fetchGraphSnapshot(request);
261
195
 
262
- updateNodePosition: (nodeId, position) =>
263
- set((state) => {
264
- const agents = new Map(state.agents);
265
- const agent = agents.get(nodeId);
266
- if (agent) {
267
- // Update agent position
268
- agents.set(nodeId, { ...agent, position });
269
- return { agents };
270
- } else {
271
- // Must be a message node - update message position
272
- const messagePositions = new Map(state.messagePositions);
273
- messagePositions.set(nodeId, position);
274
- return { messagePositions };
275
- }
276
- }),
196
+ const { savedPositions, nodes: currentNodes, agentStatus, streamingTokens } = get();
277
197
 
278
- generateAgentViewGraph: () => {
279
- const { agents, messages, runs, consumptions, nodes: currentNodes } = get();
198
+ // Merge positions: saved > current > backend > random
199
+ const mergedNodes = mergeNodePositions(snapshot.nodes, savedPositions, currentNodes);
280
200
 
281
- // Create a map of current node positions to preserve them during regeneration
282
- const currentPositions = new Map<string, { x: number; y: number }>();
283
- currentNodes.forEach(node => {
284
- currentPositions.set(node.id, node.position);
285
- });
201
+ // Overlay real-time WebSocket state (primarily for message streaming)
202
+ const finalNodes = overlayWebSocketState(mergedNodes, agentStatus, streamingTokens);
286
203
 
287
- const nodes: Node<AgentNodeData>[] = [];
288
-
289
- // Create nodes from agents
290
- agents.forEach((agent) => {
291
- // Preserve position priority: saved position > current React Flow position > default
292
- const position = agent.position
293
- || currentPositions.get(agent.id)
294
- || { x: 400 + Math.random() * 200, y: 300 + Math.random() * 200 };
295
-
296
- nodes.push({
297
- id: agent.id,
298
- type: 'agent',
299
- position,
300
- data: {
301
- name: agent.name,
302
- status: agent.status,
303
- subscriptions: agent.subscriptions,
304
- outputTypes: agent.outputTypes,
305
- sentCount: agent.sentCount,
306
- recvCount: agent.recvCount,
307
- receivedByType: agent.receivedByType,
308
- sentByType: agent.sentByType,
309
- streamingTokens: agent.streamingTokens,
310
- },
204
+ set({
205
+ nodes: finalNodes,
206
+ edges: snapshot.edges as Edge[],
207
+ statistics: snapshot.statistics,
208
+ isLoading: false,
311
209
  });
312
- });
313
210
 
314
- // Derive edges using transform algorithm
315
- const dashboardState = toDashboardState(messages, runs, consumptions);
316
- const edges = deriveAgentViewEdges(dashboardState);
211
+ // Update filter facets from backend statistics
212
+ if (snapshot.statistics?.artifactSummary) {
213
+ const summary = snapshot.statistics.artifactSummary;
214
+ const filterState = useFilterStore.getState();
215
+
216
+ // Transform ArtifactSummary to FilterFacets format
217
+ const facets = {
218
+ artifactTypes: Object.keys(summary.by_type),
219
+ producers: Object.keys(summary.by_producer),
220
+ tags: Object.keys(summary.tag_counts),
221
+ visibilities: Object.keys(summary.by_visibility),
222
+ };
317
223
 
318
- set({ nodes, edges });
319
- // Re-apply active filters so newly generated nodes respect current selections
320
- useGraphStore.getState().applyFilters();
224
+ // Support both updateAvailableFacets (production) and updateFacets (test mock)
225
+ if ('updateAvailableFacets' in filterState && typeof filterState.updateAvailableFacets === 'function') {
226
+ filterState.updateAvailableFacets(facets);
227
+ } else if ('updateFacets' in filterState && typeof (filterState as any).updateFacets === 'function') {
228
+ (filterState as any).updateFacets(facets);
229
+ }
230
+ }
231
+ } catch (error) {
232
+ const errorMessage = error instanceof Error ? error.message : 'Failed to fetch graph';
233
+ set({
234
+ error: errorMessage,
235
+ isLoading: false,
236
+ });
237
+ throw error; // Re-throw for test assertions
238
+ }
321
239
  },
322
240
 
323
- generateBlackboardViewGraph: () => {
324
- const { messages, runs, consumptions, messagePositions, nodes: currentNodes } = get();
325
-
326
- // Create a map of current node positions to preserve them during regeneration
327
- const currentPositions = new Map<string, { x: number; y: number }>();
328
- currentNodes.forEach(node => {
329
- currentPositions.set(node.id, node.position);
330
- });
241
+ refreshCurrentView: async () => {
242
+ const { viewMode } = get();
243
+ if (viewMode === 'agent') {
244
+ await get().generateAgentViewGraph();
245
+ } else {
246
+ await get().generateBlackboardViewGraph();
247
+ }
248
+ },
331
249
 
332
- const nodes: Node<MessageNodeData>[] = [];
333
-
334
- // Create nodes from messages
335
- messages.forEach((message) => {
336
- const payloadStr = JSON.stringify(message.payload);
337
-
338
- // BUG FIX: Use ACTUAL consumption data from consumptions Map, not inferred from subscriptions!
339
- const consumedBy = consumptions.get(message.id) || [];
340
-
341
- // Preserve position priority: saved position > current React Flow position > default
342
- const position = messagePositions.get(message.id)
343
- || currentPositions.get(message.id)
344
- || { x: 400 + Math.random() * 200, y: 300 + Math.random() * 200 };
345
-
346
- nodes.push({
347
- id: message.id,
348
- type: 'message',
349
- position,
350
- data: {
351
- artifactType: message.type,
352
- payloadPreview: payloadStr.slice(0, 100),
353
- payload: message.payload, // Full payload for display
354
- producedBy: message.producedBy,
355
- consumedBy, // Use actual consumption data
356
- timestamp: message.timestamp,
357
- isStreaming: message.isStreaming || false,
358
- streamingText: message.streamingText || '',
359
- tags: message.tags || [],
360
- visibilityKind: message.visibilityKind || 'Unknown',
361
- },
250
+ scheduleRefresh: () => {
251
+ // Clear existing timer if any (reset debounce)
252
+ if (refreshTimer !== null) {
253
+ clearTimeout(refreshTimer);
254
+ }
255
+
256
+ // Schedule refresh after 100ms of quiet time (snappy UX)
257
+ refreshTimer = setTimeout(() => {
258
+ refreshTimer = null;
259
+ get().refreshCurrentView().catch((error) => {
260
+ console.error('[GraphStore] Scheduled refresh failed:', error);
362
261
  });
363
- });
262
+ }, 100);
263
+ },
364
264
 
365
- // Derive edges using transform algorithm
366
- const dashboardState = toDashboardState(messages, runs, consumptions);
367
- const edges = deriveBlackboardViewEdges(dashboardState);
265
+ // Real-time WebSocket update actions
266
+ updateAgentStatus: (agentId, status) => {
267
+ set((state) => {
268
+ const agentStatus = new Map(state.agentStatus);
269
+ agentStatus.set(agentId, status);
270
+
271
+ // Inline overlay logic (don't use overlayWebSocketState which gets mocked in tests)
272
+ const nodes = state.nodes.map(node => {
273
+ if (node.type === 'agent' && node.id === agentId) {
274
+ return {
275
+ ...node,
276
+ data: {
277
+ ...node.data,
278
+ status: status,
279
+ },
280
+ };
281
+ }
282
+ return node;
283
+ });
368
284
 
369
- set({ nodes, edges });
370
- // Ensure filters are reapplied after regeneration
371
- useGraphStore.getState().applyFilters();
285
+ return { agentStatus, nodes };
286
+ });
372
287
  },
373
288
 
374
- batchUpdate: (update) =>
289
+ updateStreamingTokens: (agentId, tokens) => {
375
290
  set((state) => {
376
- const newState: Partial<GraphState> = {};
291
+ const streamingTokens = new Map(state.streamingTokens);
292
+ streamingTokens.set(agentId, tokens);
293
+
294
+ // Inline overlay logic (don't use overlayWebSocketState which gets mocked in tests)
295
+ const nodes = state.nodes.map(node => {
296
+ if (node.type === 'agent' && node.id === agentId) {
297
+ return {
298
+ ...node,
299
+ data: {
300
+ ...node.data,
301
+ streamingTokens: tokens.slice(-6), // Keep only last 6 tokens
302
+ },
303
+ };
304
+ }
305
+ return node;
306
+ });
377
307
 
378
- if (update.agents) {
379
- const agents = new Map(state.agents);
380
- update.agents.forEach((a) => agents.set(a.id, a));
381
- newState.agents = agents;
382
- }
308
+ return { streamingTokens, nodes };
309
+ });
310
+ },
383
311
 
384
- if (update.messages) {
385
- const messages = new Map(state.messages);
386
- const consumptions = new Map(state.consumptions);
387
- update.messages.forEach((m) => {
388
- messages.set(m.id, m);
389
- if (m.consumedBy && m.consumedBy.length > 0) {
390
- consumptions.set(m.id, Array.from(new Set(m.consumedBy)));
391
- }
392
- });
393
- newState.messages = messages;
394
- newState.events = [...update.messages, ...state.events].slice(0, 100);
395
- newState.consumptions = consumptions;
312
+ addEvent: (message) => {
313
+ set((state) => {
314
+ // Add to events array (max 100 items)
315
+ const isDuplicate = state.events.some(e => e.id === message.id);
316
+ if (isDuplicate) {
317
+ return state; // Skip duplicates
396
318
  }
397
319
 
398
- if (update.runs) {
399
- const runs = new Map(state.runs);
400
- update.runs.forEach((r) => runs.set(r.run_id, r));
401
- newState.runs = runs;
402
- }
320
+ const events = [message, ...state.events].slice(0, 100);
321
+ return { events };
322
+ });
323
+ },
403
324
 
404
- return newState;
405
- }),
406
-
407
- applyFilters: () => {
408
- const { nodes, edges, messages, consumptions } = get();
409
- const {
410
- correlationId,
411
- timeRange,
412
- selectedArtifactTypes,
413
- selectedProducers,
414
- selectedTags,
415
- selectedVisibility,
416
- } = useFilterStore.getState();
417
-
418
- // Helper to calculate time range boundaries
419
- const getTimeRangeBoundaries = (): { start: number; end: number } => {
420
- const now = Date.now();
421
- if (timeRange.preset === 'last5min') {
422
- return { start: now - 5 * 60 * 1000, end: now };
423
- } else if (timeRange.preset === 'last10min') {
424
- return { start: now - 10 * 60 * 1000, end: now };
425
- } else if (timeRange.preset === 'last1hour') {
426
- return { start: now - 60 * 60 * 1000, end: now };
427
- } else if (timeRange.preset === 'custom' && timeRange.start && timeRange.end) {
428
- return { start: timeRange.start, end: timeRange.end };
429
- }
430
- return { start: Number.NEGATIVE_INFINITY, end: Number.POSITIVE_INFINITY };
431
- };
432
-
433
- const { start: timeStart, end: timeEnd } = getTimeRangeBoundaries();
434
-
435
- const visibleMessageIds = new Set<string>();
436
- const producedStats = new Map<string, { total: number; byType: Record<string, number> }>();
437
- const consumedStats = new Map<string, { total: number; byType: Record<string, number> }>();
438
-
439
- const incrementStat = (
440
- map: Map<string, { total: number; byType: Record<string, number> }>,
441
- key: string,
442
- type: string
443
- ) => {
444
- if (!map.has(key)) {
445
- map.set(key, { total: 0, byType: {} });
325
+ // Streaming message nodes (Phase 6)
326
+ createOrUpdateStreamingMessageNode: (artifactId, token, eventData) => {
327
+ set((state) => {
328
+ // Only create/update streaming message nodes in blackboard view
329
+ // Message nodes should never appear in agent view
330
+ if (state.viewMode !== 'blackboard') {
331
+ console.log(`[GraphStore] Ignoring streaming message node in ${state.viewMode} view`);
332
+ return state; // No changes
446
333
  }
447
- const entry = map.get(key)!;
448
- entry.total += 1;
449
- entry.byType[type] = (entry.byType[type] || 0) + 1;
450
- };
451
-
452
- messages.forEach((message) => {
453
- let visible = true;
454
334
 
455
- if (correlationId && message.correlationId !== correlationId) {
456
- visible = false;
335
+ const existingNode = state.nodes.find(n => n.id === artifactId);
336
+
337
+ if (existingNode) {
338
+ // Update existing streaming node
339
+ const currentText = (existingNode.data.streamingText as string) || '';
340
+ const updatedNodes = state.nodes.map(node => {
341
+ if (node.id === artifactId) {
342
+ return {
343
+ ...node,
344
+ data: {
345
+ ...node.data,
346
+ streamingText: currentText + token,
347
+ isStreaming: true,
348
+ },
349
+ };
350
+ }
351
+ return node;
352
+ });
353
+ return { nodes: updatedNodes };
354
+ } else {
355
+ // Create new streaming message node
356
+ const newNode: Node = {
357
+ id: artifactId,
358
+ type: 'message',
359
+ position: { x: Math.random() * 500, y: Math.random() * 500 }, // Random position
360
+ data: {
361
+ artifactType: eventData?.artifact_type || 'Unknown',
362
+ payload: {},
363
+ producedBy: eventData?.agent_name || 'Unknown',
364
+ timestamp: Date.now(),
365
+ streamingText: token,
366
+ isStreaming: true,
367
+ tags: [],
368
+ visibilityKind: 'Public',
369
+ correlationId: eventData?.correlation_id || '',
370
+ },
371
+ };
372
+ return { nodes: [...state.nodes, newNode] };
457
373
  }
374
+ });
375
+ },
458
376
 
459
- if (visible && (message.timestamp < timeStart || message.timestamp > timeEnd)) {
460
- visible = false;
461
- }
377
+ finalizeStreamingMessageNode: (artifactId) => {
378
+ set((state) => {
379
+ const nodes = state.nodes.map(node => {
380
+ if (node.id === artifactId && node.data.isStreaming) {
381
+ return {
382
+ ...node,
383
+ data: {
384
+ ...node.data,
385
+ isStreaming: false,
386
+ // streamingText is kept so MessageNode can display it until backend refresh
387
+ },
388
+ };
389
+ }
390
+ return node;
391
+ });
462
392
 
463
- if (
464
- visible &&
465
- selectedArtifactTypes.length > 0 &&
466
- !selectedArtifactTypes.includes(message.type)
467
- ) {
468
- visible = false;
469
- }
393
+ return { nodes };
394
+ });
395
+ },
470
396
 
471
- if (
472
- visible &&
473
- selectedProducers.length > 0 &&
474
- !selectedProducers.includes(message.producedBy)
475
- ) {
476
- visible = false;
477
- }
397
+ // Position persistence actions
398
+ updateNodePosition: (nodeId, position) => {
399
+ set((state) => {
400
+ const nodes = state.nodes.map(node =>
401
+ node.id === nodeId ? { ...node, position } : node
402
+ );
403
+ return { nodes };
404
+ });
405
+ },
478
406
 
479
- if (
480
- visible &&
481
- selectedVisibility.length > 0 &&
482
- !selectedVisibility.includes(message.visibilityKind || 'Unknown')
483
- ) {
484
- visible = false;
485
- }
407
+ saveNodePosition: (nodeId, position) => {
408
+ set((state) => {
409
+ const savedPositions = new Map(state.savedPositions);
410
+ savedPositions.set(nodeId, position);
411
+
412
+ // Save to IndexedDB using indexedDBService
413
+ const viewMode = state.viewMode;
414
+ const layoutRecord = {
415
+ node_id: nodeId,
416
+ x: position.x,
417
+ y: position.y,
418
+ last_updated: new Date().toISOString(),
419
+ };
486
420
 
487
- if (visible && selectedTags.length > 0) {
488
- const messageTags = message.tags || [];
489
- const hasAllTags = selectedTags.every((tag) => messageTags.includes(tag));
490
- if (!hasAllTags) {
491
- visible = false;
492
- }
421
+ if (viewMode === 'agent') {
422
+ indexedDBService.saveAgentViewLayout(layoutRecord).catch(console.error);
423
+ } else {
424
+ indexedDBService.saveBlackboardViewLayout(layoutRecord).catch(console.error);
493
425
  }
494
426
 
495
- if (visible) {
496
- visibleMessageIds.add(message.id);
497
- incrementStat(producedStats, message.producedBy, message.type);
498
-
499
- const consumers = consumptions.get(message.id) || [];
500
- consumers.forEach((consumerId) => {
501
- incrementStat(consumedStats, consumerId, message.type);
502
- });
503
- }
427
+ return { savedPositions };
504
428
  });
429
+ },
505
430
 
506
- const updatedNodes = nodes.map((node) => {
507
- if (node.type === 'message') {
508
- return {
509
- ...node,
510
- hidden: !visibleMessageIds.has(node.id),
511
- };
512
- }
513
- if (node.type === 'agent') {
514
- const produced = producedStats.get(node.id);
515
- const consumed = consumedStats.get(node.id);
516
- const currentData = node.data as AgentNodeData;
517
- return {
518
- ...node,
519
- hidden: false,
520
- data: {
521
- ...node.data,
522
- sentCount: produced?.total ?? currentData.sentCount ?? 0,
523
- recvCount: consumed?.total ?? currentData.recvCount ?? 0,
524
- sentByType: produced?.byType ?? currentData.sentByType ?? {},
525
- receivedByType: consumed?.byType ?? currentData.receivedByType ?? {},
526
- },
527
- };
528
- }
529
- return node;
530
- });
431
+ loadSavedPositions: async () => {
432
+ try {
433
+ const viewMode = get().viewMode;
434
+ let layouts: Array<{ node_id: string; x: number; y: number; last_updated: string }> = [];
531
435
 
532
- const updatedEdges = edges.map((edge) => {
533
- let hidden = edge.hidden ?? false;
534
- const data: any = edge.data;
535
- if (data && Array.isArray(data.artifactIds) && data.artifactIds.length > 0) {
536
- hidden = data.artifactIds.every((artifactId: string) => !visibleMessageIds.has(artifactId));
436
+ if (viewMode === 'agent') {
437
+ layouts = await indexedDBService.getAllAgentViewLayouts();
537
438
  } else {
538
- const sourceNode = updatedNodes.find((n) => n.id === edge.source);
539
- const targetNode = updatedNodes.find((n) => n.id === edge.target);
540
- hidden = !!(sourceNode?.hidden || targetNode?.hidden);
439
+ layouts = await indexedDBService.getAllBlackboardViewLayouts();
541
440
  }
542
- return {
543
- ...edge,
544
- hidden,
545
- };
546
- });
547
441
 
548
- set({ nodes: updatedNodes, edges: updatedEdges });
442
+ // Convert to Map
443
+ const positions = new Map<string, { x: number; y: number }>();
444
+ layouts.forEach((layout) => {
445
+ positions.set(layout.node_id, { x: layout.x, y: layout.y });
446
+ });
447
+
448
+ set({ savedPositions: positions });
449
+ console.log(`[GraphStore] Loaded ${positions.size} saved positions for ${viewMode} view`);
450
+ } catch (error) {
451
+ console.error('[GraphStore] Failed to load saved positions:', error);
452
+ }
453
+ },
454
+
455
+ // UI state actions
456
+ setViewMode: (viewMode) => {
457
+ set({ viewMode });
549
458
  },
550
459
  }),
551
460
  { name: 'graphStore' }