flock-core 0.5.0b50__py3-none-any.whl → 0.5.0b51__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 (116) hide show
  1. flock/dashboard/launcher.py +1 -1
  2. flock/frontend/README.md +678 -0
  3. flock/frontend/docs/DESIGN_SYSTEM.md +1980 -0
  4. flock/frontend/index.html +12 -0
  5. flock/frontend/package-lock.json +4347 -0
  6. flock/frontend/package.json +48 -0
  7. flock/frontend/src/App.tsx +79 -0
  8. flock/frontend/src/__tests__/e2e/critical-scenarios.test.tsx +587 -0
  9. flock/frontend/src/__tests__/integration/filtering-e2e.test.tsx +387 -0
  10. flock/frontend/src/__tests__/integration/graph-rendering.test.tsx +640 -0
  11. flock/frontend/src/__tests__/integration/indexeddb-persistence.test.tsx +699 -0
  12. flock/frontend/src/components/common/BuildInfo.tsx +39 -0
  13. flock/frontend/src/components/common/EmptyState.module.css +115 -0
  14. flock/frontend/src/components/common/EmptyState.tsx +128 -0
  15. flock/frontend/src/components/common/ErrorBoundary.module.css +169 -0
  16. flock/frontend/src/components/common/ErrorBoundary.tsx +118 -0
  17. flock/frontend/src/components/common/KeyboardShortcutsDialog.css +251 -0
  18. flock/frontend/src/components/common/KeyboardShortcutsDialog.tsx +151 -0
  19. flock/frontend/src/components/common/LoadingSpinner.module.css +97 -0
  20. flock/frontend/src/components/common/LoadingSpinner.tsx +29 -0
  21. flock/frontend/src/components/controls/PublishControl.css +547 -0
  22. flock/frontend/src/components/controls/PublishControl.test.tsx +543 -0
  23. flock/frontend/src/components/controls/PublishControl.tsx +432 -0
  24. flock/frontend/src/components/details/DetailWindowContainer.tsx +62 -0
  25. flock/frontend/src/components/details/LiveOutputTab.test.tsx +792 -0
  26. flock/frontend/src/components/details/LiveOutputTab.tsx +220 -0
  27. flock/frontend/src/components/details/MessageHistoryTab.tsx +299 -0
  28. flock/frontend/src/components/details/NodeDetailWindow.test.tsx +501 -0
  29. flock/frontend/src/components/details/NodeDetailWindow.tsx +218 -0
  30. flock/frontend/src/components/details/RunStatusTab.tsx +307 -0
  31. flock/frontend/src/components/details/tabs.test.tsx +1015 -0
  32. flock/frontend/src/components/filters/CorrelationIDFilter.module.css +102 -0
  33. flock/frontend/src/components/filters/CorrelationIDFilter.test.tsx +197 -0
  34. flock/frontend/src/components/filters/CorrelationIDFilter.tsx +121 -0
  35. flock/frontend/src/components/filters/FilterBar.module.css +29 -0
  36. flock/frontend/src/components/filters/FilterBar.test.tsx +133 -0
  37. flock/frontend/src/components/filters/FilterBar.tsx +33 -0
  38. flock/frontend/src/components/filters/FilterPills.module.css +79 -0
  39. flock/frontend/src/components/filters/FilterPills.test.tsx +173 -0
  40. flock/frontend/src/components/filters/FilterPills.tsx +67 -0
  41. flock/frontend/src/components/filters/TimeRangeFilter.module.css +91 -0
  42. flock/frontend/src/components/filters/TimeRangeFilter.test.tsx +154 -0
  43. flock/frontend/src/components/filters/TimeRangeFilter.tsx +105 -0
  44. flock/frontend/src/components/graph/AgentNode.test.tsx +75 -0
  45. flock/frontend/src/components/graph/AgentNode.tsx +322 -0
  46. flock/frontend/src/components/graph/GraphCanvas.tsx +406 -0
  47. flock/frontend/src/components/graph/MessageFlowEdge.tsx +128 -0
  48. flock/frontend/src/components/graph/MessageNode.test.tsx +62 -0
  49. flock/frontend/src/components/graph/MessageNode.tsx +116 -0
  50. flock/frontend/src/components/graph/MiniMap.tsx +47 -0
  51. flock/frontend/src/components/graph/TransformEdge.tsx +123 -0
  52. flock/frontend/src/components/layout/DashboardLayout.css +407 -0
  53. flock/frontend/src/components/layout/DashboardLayout.tsx +300 -0
  54. flock/frontend/src/components/layout/Header.module.css +88 -0
  55. flock/frontend/src/components/layout/Header.tsx +52 -0
  56. flock/frontend/src/components/modules/EventLogModule.test.tsx +401 -0
  57. flock/frontend/src/components/modules/EventLogModule.tsx +396 -0
  58. flock/frontend/src/components/modules/EventLogModuleWrapper.tsx +17 -0
  59. flock/frontend/src/components/modules/ModuleRegistry.test.ts +333 -0
  60. flock/frontend/src/components/modules/ModuleRegistry.ts +85 -0
  61. flock/frontend/src/components/modules/ModuleWindow.tsx +155 -0
  62. flock/frontend/src/components/modules/registerModules.ts +20 -0
  63. flock/frontend/src/components/settings/AdvancedSettings.tsx +175 -0
  64. flock/frontend/src/components/settings/AppearanceSettings.tsx +185 -0
  65. flock/frontend/src/components/settings/GraphSettings.tsx +110 -0
  66. flock/frontend/src/components/settings/SettingsPanel.css +327 -0
  67. flock/frontend/src/components/settings/SettingsPanel.tsx +131 -0
  68. flock/frontend/src/components/settings/ThemeSelector.tsx +298 -0
  69. flock/frontend/src/hooks/useKeyboardShortcuts.ts +148 -0
  70. flock/frontend/src/hooks/useModulePersistence.test.ts +442 -0
  71. flock/frontend/src/hooks/useModulePersistence.ts +154 -0
  72. flock/frontend/src/hooks/useModules.ts +139 -0
  73. flock/frontend/src/hooks/usePersistence.ts +139 -0
  74. flock/frontend/src/main.tsx +13 -0
  75. flock/frontend/src/services/api.ts +213 -0
  76. flock/frontend/src/services/indexeddb.test.ts +793 -0
  77. flock/frontend/src/services/indexeddb.ts +794 -0
  78. flock/frontend/src/services/layout.test.ts +437 -0
  79. flock/frontend/src/services/layout.ts +146 -0
  80. flock/frontend/src/services/themeApplicator.ts +140 -0
  81. flock/frontend/src/services/themeService.ts +77 -0
  82. flock/frontend/src/services/websocket.test.ts +595 -0
  83. flock/frontend/src/services/websocket.ts +685 -0
  84. flock/frontend/src/store/filterStore.test.ts +242 -0
  85. flock/frontend/src/store/filterStore.ts +103 -0
  86. flock/frontend/src/store/graphStore.test.ts +186 -0
  87. flock/frontend/src/store/graphStore.ts +414 -0
  88. flock/frontend/src/store/moduleStore.test.ts +253 -0
  89. flock/frontend/src/store/moduleStore.ts +57 -0
  90. flock/frontend/src/store/settingsStore.ts +188 -0
  91. flock/frontend/src/store/streamStore.ts +68 -0
  92. flock/frontend/src/store/uiStore.test.ts +54 -0
  93. flock/frontend/src/store/uiStore.ts +110 -0
  94. flock/frontend/src/store/wsStore.ts +34 -0
  95. flock/frontend/src/styles/index.css +15 -0
  96. flock/frontend/src/styles/scrollbar.css +47 -0
  97. flock/frontend/src/styles/variables.css +488 -0
  98. flock/frontend/src/test/setup.ts +1 -0
  99. flock/frontend/src/types/filters.ts +14 -0
  100. flock/frontend/src/types/graph.ts +55 -0
  101. flock/frontend/src/types/modules.ts +7 -0
  102. flock/frontend/src/types/theme.ts +55 -0
  103. flock/frontend/src/utils/mockData.ts +85 -0
  104. flock/frontend/src/utils/performance.ts +16 -0
  105. flock/frontend/src/utils/transforms.test.ts +860 -0
  106. flock/frontend/src/utils/transforms.ts +323 -0
  107. flock/frontend/src/vite-env.d.ts +17 -0
  108. flock/frontend/tsconfig.json +27 -0
  109. flock/frontend/tsconfig.node.json +11 -0
  110. flock/frontend/vite.config.ts +25 -0
  111. flock/frontend/vitest.config.ts +11 -0
  112. {flock_core-0.5.0b50.dist-info → flock_core-0.5.0b51.dist-info}/METADATA +1 -1
  113. {flock_core-0.5.0b50.dist-info → flock_core-0.5.0b51.dist-info}/RECORD +116 -6
  114. {flock_core-0.5.0b50.dist-info → flock_core-0.5.0b51.dist-info}/WHEEL +0 -0
  115. {flock_core-0.5.0b50.dist-info → flock_core-0.5.0b51.dist-info}/entry_points.txt +0 -0
  116. {flock_core-0.5.0b50.dist-info → flock_core-0.5.0b51.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,685 @@
1
+ import { useWSStore } from '../store/wsStore';
2
+ import { useGraphStore } from '../store/graphStore';
3
+
4
+ interface WebSocketMessage {
5
+ event_type: 'agent_activated' | 'message_published' | 'streaming_output' | 'agent_completed' | 'agent_error';
6
+ timestamp: string;
7
+ correlation_id: string;
8
+ session_id: string;
9
+ data: any;
10
+ }
11
+
12
+ interface StoreInterface {
13
+ addAgent: (agent: any) => void;
14
+ updateAgent: (id: string, updates: any) => void;
15
+ addMessage: (message: any) => void;
16
+ updateMessage: (id: string, updates: any) => void;
17
+ batchUpdate?: (update: any) => void;
18
+ }
19
+
20
+ export class WebSocketClient {
21
+ ws: WebSocket | null = null;
22
+ private reconnectTimeout: number | null = null;
23
+ private reconnectAttempt = 0;
24
+ private maxReconnectDelay = 30000; // 30 seconds
25
+ private connectionTimeout: number | null = null;
26
+ private connectionTimeoutMs = 10000; // 10 seconds
27
+ private messageBuffer: any[] = [];
28
+ private maxBufferSize = 100;
29
+ private eventHandlers: Map<string, ((data: any) => void)[]> = new Map();
30
+ private url: string;
31
+ private shouldReconnect = true;
32
+ private heartbeatInterval: number | null = null;
33
+ private heartbeatTimeout: number | null = null;
34
+ private connectionStatus: 'connecting' | 'connected' | 'disconnected' | 'disconnecting' | 'error' = 'disconnected';
35
+ private store: StoreInterface;
36
+ private enableHeartbeat: boolean;
37
+
38
+ constructor(url: string, mockStore?: StoreInterface) {
39
+ this.url = url;
40
+ this.store = mockStore || {
41
+ addAgent: (agent: any) => useGraphStore.getState().addAgent(agent),
42
+ updateAgent: (id: string, updates: any) => useGraphStore.getState().updateAgent(id, updates),
43
+ addMessage: (message: any) => useGraphStore.getState().addMessage(message),
44
+ updateMessage: (id: string, updates: any) => useGraphStore.getState().updateMessage(id, updates),
45
+ batchUpdate: (update: any) => useGraphStore.getState().batchUpdate(update),
46
+ };
47
+ // Phase 11 Fix: Disable heartbeat entirely - it causes unnecessary disconnects
48
+ // WebSocket auto-reconnects on real network issues without needing heartbeat
49
+ // The heartbeat was closing connections every 2min when backend didn't respond to pings
50
+ this.enableHeartbeat = false;
51
+ this.setupEventHandlers();
52
+ }
53
+
54
+ private setupEventHandlers(): void {
55
+ // Handler for agent_activated: create/update agent in graph AND create Run
56
+ this.on('agent_activated', (data) => {
57
+ const agents = useGraphStore.getState().agents;
58
+ const messages = useGraphStore.getState().messages;
59
+ const existingAgent = agents.get(data.agent_id);
60
+
61
+ // Count received messages by type
62
+ const receivedByType = { ...(existingAgent?.receivedByType || {}) };
63
+ if (data.consumed_artifacts && data.consumed_artifacts.length > 0) {
64
+ // Look up each consumed artifact and count by type
65
+ data.consumed_artifacts.forEach((artifactId: string) => {
66
+ const message = messages.get(artifactId);
67
+ if (message) {
68
+ receivedByType[message.type] = (receivedByType[message.type] || 0) + 1;
69
+ }
70
+ });
71
+ }
72
+
73
+ // Bug Fix #2: Preserve sentCount/recvCount if agent already exists
74
+ // Otherwise counters get reset to 0 on each activation
75
+ const agent = {
76
+ id: data.agent_id,
77
+ name: data.agent_name,
78
+ status: 'running' as const,
79
+ subscriptions: data.consumed_types || [],
80
+ lastActive: Date.now(),
81
+ sentCount: existingAgent?.sentCount || 0, // Preserve existing count
82
+ recvCount: (existingAgent?.recvCount || 0) + (data.consumed_artifacts?.length || 0), // Add new consumed artifacts
83
+ outputTypes: data.produced_types || [], // Get output types from backend
84
+ receivedByType, // Track per-type received counts
85
+ sentByType: existingAgent?.sentByType || {}, // Preserve sent counts
86
+ };
87
+ this.store.addAgent(agent);
88
+
89
+ // Phase 11 Bug Fix: Record actual consumption to track filtering
90
+ // This enables showing "(3, filtered: 1)" on edges
91
+ if (data.consumed_artifacts && data.consumed_artifacts.length > 0) {
92
+ useGraphStore.getState().recordConsumption(data.consumed_artifacts, data.agent_id);
93
+ }
94
+
95
+ // Create Run object for Blackboard View edges
96
+ // Bug Fix: Use run_id from backend (unique per agent activation) instead of correlation_id
97
+ const run = {
98
+ run_id: data.run_id || `run_${Date.now()}`, // data.run_id is ctx.task_id from backend
99
+ agent_name: data.agent_name,
100
+ correlation_id: data.correlation_id, // Separate field for grouping runs
101
+ status: 'active' as const,
102
+ consumed_artifacts: data.consumed_artifacts || [],
103
+ produced_artifacts: [], // Will be populated on message_published
104
+ started_at: new Date().toISOString(),
105
+ };
106
+ if (this.store.batchUpdate) {
107
+ this.store.batchUpdate({ runs: [run] });
108
+ }
109
+ });
110
+
111
+ // Handler for message_published: update existing streaming message or create new one
112
+ this.on('message_published', (data) => {
113
+ // Finalize or create the message
114
+ const messages = useGraphStore.getState().messages;
115
+ const streamingMessageId = `streaming_${data.produced_by}_${data.correlation_id}`;
116
+ const existingMessage = messages.get(streamingMessageId);
117
+
118
+ if (existingMessage) {
119
+ // Update existing streaming message with final data
120
+ const finalMessage = {
121
+ ...existingMessage,
122
+ id: data.artifact_id, // Replace temp ID with real artifact ID
123
+ type: data.artifact_type,
124
+ payload: data.payload,
125
+ isStreaming: false, // Streaming complete
126
+ streamingText: '', // Clear streaming text
127
+ };
128
+
129
+ // Use store action to properly update (triggers graph regeneration)
130
+ useGraphStore.getState().finalizeStreamingMessage(streamingMessageId, finalMessage);
131
+ } else {
132
+ // No streaming message - create new message directly
133
+ const message = {
134
+ id: data.artifact_id,
135
+ type: data.artifact_type,
136
+ payload: data.payload,
137
+ timestamp: data.timestamp ? new Date(data.timestamp).getTime() : Date.now(),
138
+ correlationId: data.correlation_id || '',
139
+ producedBy: data.produced_by,
140
+ isStreaming: false,
141
+ };
142
+ this.store.addMessage(message);
143
+ }
144
+
145
+ // Update producer agent counters (outputTypes come from agent_activated event now)
146
+ const producer = useGraphStore.getState().agents.get(data.produced_by);
147
+ if (producer) {
148
+ // Track sent count by type
149
+ const sentByType = { ...(producer.sentByType || {}) };
150
+ sentByType[data.artifact_type] = (sentByType[data.artifact_type] || 0) + 1;
151
+
152
+ this.store.updateAgent(data.produced_by, {
153
+ sentCount: (producer.sentCount || 0) + 1,
154
+ lastActive: Date.now(),
155
+ sentByType,
156
+ });
157
+ } else {
158
+ // Producer doesn't exist as a registered agent - create virtual agent
159
+ // This handles orchestrator-published artifacts (e.g., initial Idea from dashboard PublishControl)
160
+ this.store.addAgent({
161
+ id: data.produced_by,
162
+ name: data.produced_by,
163
+ status: 'idle' as const,
164
+ subscriptions: [],
165
+ lastActive: Date.now(),
166
+ sentCount: 1,
167
+ recvCount: 0,
168
+ outputTypes: [data.artifact_type], // Virtual agents get type from their first message
169
+ sentByType: { [data.artifact_type]: 1 },
170
+ receivedByType: {},
171
+ });
172
+ }
173
+
174
+ // Phase 11 Bug Fix: Increment consumers' recv count instead of setting to 1
175
+ if (data.consumers && Array.isArray(data.consumers)) {
176
+ const agents = useGraphStore.getState().agents;
177
+ data.consumers.forEach((consumerId: string) => {
178
+ const consumer = agents.get(consumerId);
179
+ if (consumer) {
180
+ this.store.updateAgent(consumerId, {
181
+ recvCount: (consumer.recvCount || 0) + 1,
182
+ lastActive: Date.now(),
183
+ });
184
+ }
185
+ });
186
+ }
187
+
188
+ // Update Run with produced artifact for Blackboard View edges
189
+ // Bug Fix: Find Run by agent_name + correlation_id since run_id is not in message_published event
190
+ if (data.correlation_id && this.store.batchUpdate) {
191
+ const runs = useGraphStore.getState().runs;
192
+ // Find the active Run for this agent + correlation_id
193
+ const run = Array.from(runs.values()).find(
194
+ r => r.agent_name === data.produced_by &&
195
+ r.correlation_id === data.correlation_id &&
196
+ r.status === 'active'
197
+ );
198
+ if (run) {
199
+ // Add artifact to produced_artifacts if not already present
200
+ if (!run.produced_artifacts.includes(data.artifact_id)) {
201
+ const updatedRun = {
202
+ ...run,
203
+ produced_artifacts: [...run.produced_artifacts, data.artifact_id],
204
+ };
205
+ this.store.batchUpdate({ runs: [updatedRun] });
206
+ }
207
+ }
208
+ }
209
+ });
210
+
211
+ // Handler for streaming_output: update live output (Phase 6)
212
+ this.on('streaming_output', (data) => {
213
+ // Phase 6: Update detail window live output
214
+ console.log('[WebSocket] Streaming output:', data);
215
+ // Update agent to show it's active and track streaming tokens for news ticker
216
+ if (data.agent_name && data.output_type === 'llm_token') {
217
+ const agents = useGraphStore.getState().agents;
218
+ const agent = agents.get(data.agent_name);
219
+ const currentTokens = agent?.streamingTokens || [];
220
+
221
+ // Keep only last 6 tokens (news ticker effect)
222
+ const updatedTokens = [...currentTokens, data.content].slice(-6);
223
+
224
+ this.store.updateAgent(data.agent_name, {
225
+ lastActive: Date.now(),
226
+ streamingTokens: updatedTokens,
227
+ });
228
+ }
229
+
230
+ // Create/update streaming message node for blackboard view
231
+ if (data.output_type === 'llm_token' && data.agent_name && data.correlation_id) {
232
+ const messages = useGraphStore.getState().messages;
233
+ // Use agent_name + correlation_id as temporary ID
234
+ const streamingMessageId = `streaming_${data.agent_name}_${data.correlation_id}`;
235
+ const existingMessage = messages.get(streamingMessageId);
236
+
237
+ if (existingMessage) {
238
+ // Append token to existing streaming message using updateMessage
239
+ // This updates the messages Map without flooding the events array
240
+ this.store.updateMessage(streamingMessageId, {
241
+ streamingText: (existingMessage.streamingText || '') + data.content,
242
+ timestamp: data.timestamp ? new Date(data.timestamp).getTime() : Date.now(),
243
+ });
244
+ } else if (data.sequence === 0 || !existingMessage) {
245
+ // Look up agent's typical output type
246
+ const agents = useGraphStore.getState().agents;
247
+ const agent = agents.get(data.agent_name);
248
+ const outputType = agent?.outputTypes?.[0] || 'output';
249
+
250
+ // Create new streaming message on first token
251
+ const streamingMessage = {
252
+ id: streamingMessageId,
253
+ type: outputType, // Use agent's known output type
254
+ payload: {},
255
+ timestamp: data.timestamp ? new Date(data.timestamp).getTime() : Date.now(),
256
+ correlationId: data.correlation_id || '',
257
+ producedBy: data.agent_name,
258
+ isStreaming: true,
259
+ streamingText: data.content,
260
+ };
261
+ this.store.addMessage(streamingMessage);
262
+ }
263
+ }
264
+
265
+ // Note: The actual output storage is handled by LiveOutputTab's event listener
266
+ // This handler is for store updates only
267
+ });
268
+
269
+ // Handler for agent_completed: update agent status to idle
270
+ this.on('agent_completed', (data) => {
271
+ this.store.updateAgent(data.agent_name, {
272
+ status: 'idle',
273
+ lastActive: Date.now(),
274
+ streamingTokens: [], // Clear news ticker on completion
275
+ });
276
+
277
+ // Update Run status to completed for Blackboard View edges
278
+ // Bug Fix: Use run_id from event data (agent_completed has run_id)
279
+ if (data.run_id && this.store.batchUpdate) {
280
+ const runs = useGraphStore.getState().runs;
281
+ const run = runs.get(data.run_id);
282
+ if (run) {
283
+ const updatedRun = {
284
+ ...run,
285
+ status: 'completed' as const,
286
+ completed_at: new Date().toISOString(),
287
+ duration_ms: data.duration_ms,
288
+ };
289
+ this.store.batchUpdate({ runs: [updatedRun] });
290
+ }
291
+ }
292
+ });
293
+
294
+ // Handler for agent_error: update agent status to error
295
+ this.on('agent_error', (data) => {
296
+ this.store.updateAgent(data.agent_name, {
297
+ status: 'error',
298
+ lastActive: Date.now(),
299
+ });
300
+
301
+ // Update Run status to error
302
+ // Bug Fix: Use run_id from event data (agent_error has run_id)
303
+ if (data.run_id && this.store.batchUpdate) {
304
+ const runs = useGraphStore.getState().runs;
305
+ const run = runs.get(data.run_id);
306
+ if (run) {
307
+ const updatedRun = {
308
+ ...run,
309
+ status: 'error' as const,
310
+ completed_at: new Date().toISOString(),
311
+ error_message: data.error_message || 'Unknown error',
312
+ };
313
+ this.store.batchUpdate({ runs: [updatedRun] });
314
+ }
315
+ }
316
+ });
317
+
318
+ // Handler for ping: respond with pong
319
+ this.on('ping', () => {
320
+ this.send({ type: 'pong', timestamp: Date.now() });
321
+ });
322
+ }
323
+
324
+ connect(): void {
325
+ if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) {
326
+ return;
327
+ }
328
+
329
+ try {
330
+ this.connectionStatus = 'connecting';
331
+ if (typeof useWSStore !== 'undefined') {
332
+ useWSStore.getState().setStatus('connecting');
333
+ }
334
+
335
+ this.ws = new WebSocket(this.url);
336
+
337
+ // Set connection timeout
338
+ this.connectionTimeout = window.setTimeout(() => {
339
+ console.warn('[WebSocket] Connection timeout');
340
+ if (this.ws && this.ws.readyState !== WebSocket.OPEN) {
341
+ this.ws.close();
342
+ this.connectionStatus = 'error';
343
+ if (typeof useWSStore !== 'undefined') {
344
+ useWSStore.getState().setStatus('disconnected');
345
+ useWSStore.getState().setError('Connection timeout');
346
+ }
347
+ if (this.shouldReconnect) {
348
+ this.reconnect();
349
+ }
350
+ }
351
+ }, this.connectionTimeoutMs);
352
+
353
+ this.ws.onopen = () => {
354
+ console.log('[WebSocket] Connected');
355
+
356
+ // Clear connection timeout
357
+ if (this.connectionTimeout !== null) {
358
+ clearTimeout(this.connectionTimeout);
359
+ this.connectionTimeout = null;
360
+ }
361
+
362
+ this.connectionStatus = 'connected';
363
+ if (typeof useWSStore !== 'undefined') {
364
+ useWSStore.getState().setStatus('connected');
365
+ useWSStore.getState().setError(null);
366
+ useWSStore.getState().resetAttempts();
367
+ }
368
+ this.reconnectAttempt = 0;
369
+ this.flushBuffer();
370
+ if (this.enableHeartbeat) {
371
+ this.startHeartbeat();
372
+ }
373
+ };
374
+
375
+ this.ws.onmessage = (event: MessageEvent) => {
376
+ this.handleMessage(event);
377
+ };
378
+
379
+ this.ws.onerror = (error) => {
380
+ console.error('[WebSocket] Error:', error);
381
+ // Keep connection status as error even after close event
382
+ this.connectionStatus = 'error';
383
+ if (typeof useWSStore !== 'undefined') {
384
+ useWSStore.getState().setError('Connection error');
385
+ useWSStore.getState().setStatus('disconnected');
386
+ }
387
+ };
388
+
389
+ this.ws.onclose = (event) => {
390
+ console.log('[WebSocket] Closed:', event.code, event.reason);
391
+ this.stopHeartbeat();
392
+
393
+ // Don't override error status
394
+ if (this.connectionStatus !== 'error') {
395
+ if (this.shouldReconnect && event.code !== 1000) {
396
+ this.connectionStatus = 'connecting'; // Will be reconnecting
397
+ if (typeof useWSStore !== 'undefined') {
398
+ useWSStore.getState().setStatus('reconnecting');
399
+ }
400
+ this.reconnect();
401
+ } else {
402
+ this.connectionStatus = 'disconnected';
403
+ if (typeof useWSStore !== 'undefined') {
404
+ useWSStore.getState().setStatus('disconnected');
405
+ }
406
+ }
407
+ }
408
+ };
409
+ } catch (error) {
410
+ console.error('[WebSocket] Connection failed:', error);
411
+ this.connectionStatus = 'error';
412
+ if (typeof useWSStore !== 'undefined') {
413
+ useWSStore.getState().setStatus('disconnected');
414
+ useWSStore.getState().setError(error instanceof Error ? error.message : 'Connection failed');
415
+ }
416
+ if (this.shouldReconnect) {
417
+ this.reconnect();
418
+ }
419
+ }
420
+ }
421
+
422
+ private reconnect(): void {
423
+ if (this.reconnectTimeout !== null) {
424
+ return; // Already scheduled
425
+ }
426
+
427
+ // Exponential backoff: 1s, 2s, 4s, 8s, max 30s
428
+ const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempt), this.maxReconnectDelay);
429
+
430
+ if (typeof useWSStore !== 'undefined') {
431
+ useWSStore.getState().incrementAttempts();
432
+ }
433
+ this.reconnectAttempt++;
434
+
435
+ console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`);
436
+
437
+ this.reconnectTimeout = window.setTimeout(() => {
438
+ this.reconnectTimeout = null;
439
+ this.connect();
440
+ }, delay);
441
+ }
442
+
443
+ private handleMessage(event: MessageEvent): void {
444
+ try {
445
+ const data = JSON.parse(event.data);
446
+
447
+ // Handle direct type field (for ping/pong)
448
+ if (data.type === 'ping') {
449
+ this.send({ type: 'pong', timestamp: Date.now() });
450
+ return;
451
+ }
452
+
453
+ if (data.type === 'pong') {
454
+ this.resetHeartbeatTimeout();
455
+ return;
456
+ }
457
+
458
+ // Handle WebSocketMessage envelope
459
+ const message: WebSocketMessage = data;
460
+
461
+ // Handle pong as event_type
462
+ if (message.event_type === 'pong' as any) {
463
+ this.resetHeartbeatTimeout();
464
+ return;
465
+ }
466
+
467
+ // Determine if this is an envelope or raw data
468
+ // If it has event_type, it's an envelope; use message.data
469
+ // Otherwise, it's raw data (for tests)
470
+ const eventData = message.event_type ? message.data : data;
471
+
472
+ // Try to detect event type from data
473
+ let eventType = message.event_type;
474
+ if (!eventType) {
475
+ // Infer event type from data structure for test compatibility
476
+ if (data.agent_id && data.consumed_types) {
477
+ eventType = 'agent_activated';
478
+ } else if (data.artifact_id && data.artifact_type) {
479
+ eventType = 'message_published';
480
+ } else if (data.run_id && data.output_type) {
481
+ eventType = 'streaming_output';
482
+ } else if (data.run_id && data.duration_ms !== undefined) {
483
+ eventType = 'agent_completed';
484
+ } else if (data.run_id && data.error_type) {
485
+ eventType = 'agent_error';
486
+ }
487
+ }
488
+
489
+ // Dispatch to registered handlers
490
+ if (eventType) {
491
+ const handlers = this.eventHandlers.get(eventType);
492
+ if (handlers) {
493
+ handlers.forEach((handler) => {
494
+ try {
495
+ handler(eventData);
496
+ } catch (error) {
497
+ console.error(`[WebSocket] Handler error for ${eventType}:`, error);
498
+ }
499
+ });
500
+ }
501
+ }
502
+ } catch (error) {
503
+ console.error('[WebSocket] Failed to parse message:', error);
504
+ }
505
+ }
506
+
507
+ send(message: any): void {
508
+ if (this.ws?.readyState === WebSocket.OPEN) {
509
+ try {
510
+ this.ws.send(JSON.stringify(message));
511
+ } catch (error) {
512
+ console.error('[WebSocket] Send failed:', error);
513
+ this.bufferMessage(message);
514
+ }
515
+ } else {
516
+ this.bufferMessage(message);
517
+ }
518
+ }
519
+
520
+ private bufferMessage(message: any): void {
521
+ if (this.messageBuffer.length >= this.maxBufferSize) {
522
+ this.messageBuffer.shift(); // Remove oldest message
523
+ }
524
+ this.messageBuffer.push(message);
525
+ }
526
+
527
+ private flushBuffer(): void {
528
+ if (this.messageBuffer.length === 0) {
529
+ return;
530
+ }
531
+
532
+ console.log(`[WebSocket] Flushing ${this.messageBuffer.length} buffered messages`);
533
+
534
+ const messages = [...this.messageBuffer];
535
+ this.messageBuffer = [];
536
+
537
+ messages.forEach((message) => {
538
+ // Send directly to avoid re-buffering
539
+ if (this.ws?.readyState === WebSocket.OPEN) {
540
+ try {
541
+ this.ws.send(JSON.stringify(message));
542
+ } catch (error) {
543
+ console.error('[WebSocket] Failed to send buffered message:', error);
544
+ }
545
+ }
546
+ });
547
+ }
548
+
549
+ on(eventType: string, handler: (data: any) => void): void {
550
+ if (!this.eventHandlers.has(eventType)) {
551
+ this.eventHandlers.set(eventType, []);
552
+ }
553
+ this.eventHandlers.get(eventType)!.push(handler);
554
+ }
555
+
556
+ off(eventType: string, handler: (data: any) => void): void {
557
+ const handlers = this.eventHandlers.get(eventType);
558
+ if (handlers) {
559
+ const index = handlers.indexOf(handler);
560
+ if (index > -1) {
561
+ handlers.splice(index, 1);
562
+ }
563
+ }
564
+ }
565
+
566
+ private startHeartbeat(): void {
567
+ this.stopHeartbeat();
568
+
569
+ // Send ping every 2 minutes
570
+ this.heartbeatInterval = window.setInterval(() => {
571
+ if (this.ws?.readyState === WebSocket.OPEN) {
572
+ this.send({ type: 'ping' });
573
+
574
+ // Set timeout for pong response (10 seconds)
575
+ this.heartbeatTimeout = window.setTimeout(() => {
576
+ console.warn('[WebSocket] Heartbeat timeout, closing connection');
577
+ this.ws?.close();
578
+ }, 10000);
579
+ }
580
+ }, 120000);
581
+ }
582
+
583
+ private stopHeartbeat(): void {
584
+ if (this.heartbeatInterval !== null) {
585
+ clearInterval(this.heartbeatInterval);
586
+ this.heartbeatInterval = null;
587
+ }
588
+ if (this.heartbeatTimeout !== null) {
589
+ clearTimeout(this.heartbeatTimeout);
590
+ this.heartbeatTimeout = null;
591
+ }
592
+ }
593
+
594
+ private resetHeartbeatTimeout(): void {
595
+ if (this.heartbeatTimeout !== null) {
596
+ clearTimeout(this.heartbeatTimeout);
597
+ this.heartbeatTimeout = null;
598
+ }
599
+ }
600
+
601
+ disconnect(): void {
602
+ this.shouldReconnect = false;
603
+ this.connectionStatus = 'disconnecting';
604
+
605
+ if (this.reconnectTimeout !== null) {
606
+ clearTimeout(this.reconnectTimeout);
607
+ this.reconnectTimeout = null;
608
+ }
609
+
610
+ if (this.connectionTimeout !== null) {
611
+ clearTimeout(this.connectionTimeout);
612
+ this.connectionTimeout = null;
613
+ }
614
+
615
+ this.stopHeartbeat();
616
+
617
+ if (this.ws) {
618
+ this.ws.close();
619
+ this.ws = null;
620
+ }
621
+
622
+ // Status will be set to 'disconnected' by onclose handler
623
+ // Don't override it here to maintain proper status flow
624
+ }
625
+
626
+ reconnectManually(): void {
627
+ this.shouldReconnect = true;
628
+ this.reconnectAttempt = 0;
629
+ if (typeof useWSStore !== 'undefined') {
630
+ useWSStore.getState().resetAttempts();
631
+ }
632
+ this.connect();
633
+ }
634
+
635
+ // Test helper methods
636
+ isConnected(): boolean {
637
+ return this.ws?.readyState === WebSocket.OPEN && this.connectionStatus !== 'error';
638
+ }
639
+
640
+ getConnectionStatus(): string {
641
+ return this.connectionStatus;
642
+ }
643
+
644
+ getBufferedMessageCount(): number {
645
+ return this.messageBuffer.length;
646
+ }
647
+
648
+ getStatus(): string {
649
+ if (!this.ws) return 'disconnected';
650
+
651
+ switch (this.ws.readyState) {
652
+ case WebSocket.CONNECTING:
653
+ return 'connecting';
654
+ case WebSocket.OPEN:
655
+ return 'connected';
656
+ case WebSocket.CLOSING:
657
+ return 'disconnecting';
658
+ case WebSocket.CLOSED:
659
+ return 'disconnected';
660
+ default:
661
+ return 'disconnected';
662
+ }
663
+ }
664
+ }
665
+
666
+ // Singleton instance
667
+ let wsClient: WebSocketClient | null = null;
668
+
669
+ export const getWebSocketClient = (url?: string): WebSocketClient => {
670
+ if (!wsClient && url) {
671
+ wsClient = new WebSocketClient(url);
672
+ }
673
+ if (!wsClient) {
674
+ throw new Error('WebSocket client not initialized');
675
+ }
676
+ return wsClient;
677
+ };
678
+
679
+ export const initializeWebSocket = (url: string): WebSocketClient => {
680
+ if (wsClient) {
681
+ wsClient.disconnect();
682
+ }
683
+ wsClient = new WebSocketClient(url);
684
+ return wsClient;
685
+ };