flock-core 0.5.3__py3-none-any.whl → 0.5.4__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 (29) hide show
  1. flock/artifact_collector.py +2 -3
  2. flock/batch_accumulator.py +4 -4
  3. flock/correlation_engine.py +9 -4
  4. flock/dashboard/collector.py +4 -0
  5. flock/dashboard/events.py +74 -0
  6. flock/dashboard/graph_builder.py +272 -0
  7. flock/dashboard/models/graph.py +3 -1
  8. flock/dashboard/service.py +363 -14
  9. flock/frontend/package.json +1 -1
  10. flock/frontend/src/components/controls/PublishControl.test.tsx +11 -11
  11. flock/frontend/src/components/controls/PublishControl.tsx +1 -1
  12. flock/frontend/src/components/graph/AgentNode.tsx +4 -0
  13. flock/frontend/src/components/graph/GraphCanvas.tsx +4 -0
  14. flock/frontend/src/components/graph/LogicOperationsDisplay.tsx +463 -0
  15. flock/frontend/src/components/graph/PendingBatchEdge.tsx +141 -0
  16. flock/frontend/src/components/graph/PendingJoinEdge.tsx +144 -0
  17. flock/frontend/src/services/graphService.ts +3 -1
  18. flock/frontend/src/services/websocket.ts +99 -1
  19. flock/frontend/src/store/graphStore.test.ts +2 -1
  20. flock/frontend/src/store/graphStore.ts +36 -5
  21. flock/frontend/src/types/graph.ts +86 -0
  22. flock/orchestrator.py +127 -1
  23. flock/patches/__init__.py +1 -0
  24. flock/patches/dspy_streaming_patch.py +1 -0
  25. {flock_core-0.5.3.dist-info → flock_core-0.5.4.dist-info}/METADATA +9 -1
  26. {flock_core-0.5.3.dist-info → flock_core-0.5.4.dist-info}/RECORD +29 -26
  27. {flock_core-0.5.3.dist-info → flock_core-0.5.4.dist-info}/WHEEL +0 -0
  28. {flock_core-0.5.3.dist-info → flock_core-0.5.4.dist-info}/entry_points.txt +0 -0
  29. {flock_core-0.5.3.dist-info → flock_core-0.5.4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,144 @@
1
+ import React from 'react';
2
+ import {
3
+ BaseEdge,
4
+ EdgeProps,
5
+ getBezierPath,
6
+ getStraightPath,
7
+ getSmoothStepPath,
8
+ getSimpleBezierPath,
9
+ EdgeLabelRenderer
10
+ } from '@xyflow/react';
11
+ import { useSettingsStore } from '../../store/settingsStore';
12
+
13
+ /**
14
+ * Phase 1.5: Logic Operations UX - PendingJoinEdge Component
15
+ *
16
+ * Custom edge for showing artifacts "en route" to JoinSpec correlation groups.
17
+ * Visually distinct from normal message_flow edges to indicate waiting state.
18
+ *
19
+ * Features:
20
+ * - Purple dashed line (matches JoinSpec theme)
21
+ * - Label with ⋈ symbol + correlation key
22
+ * - Hover tooltip showing waiting_for types
23
+ * - Animated dashing to show "pending" state
24
+ *
25
+ * Edge type: "pending_join"
26
+ * Created by backend graph_builder.py when artifacts are waiting in correlation groups
27
+ */
28
+
29
+ export interface PendingJoinEdgeData {
30
+ artifactId: string;
31
+ artifactType: string;
32
+ correlationKey: string;
33
+ subscriptionIndex: number;
34
+ waitingFor: string[];
35
+ labelOffset?: number;
36
+ }
37
+
38
+ const PendingJoinEdge: React.FC<EdgeProps> = ({
39
+ id,
40
+ sourceX,
41
+ sourceY,
42
+ targetX,
43
+ targetY,
44
+ sourcePosition,
45
+ targetPosition,
46
+ label,
47
+ style = {},
48
+ markerEnd,
49
+ data,
50
+ }) => {
51
+ // Get edge settings from settings store
52
+ const edgeType = useSettingsStore((state) => state.graph.edgeType);
53
+ const edgeStrokeWidth = useSettingsStore((state) => state.graph.edgeStrokeWidth);
54
+ const showEdgeLabels = useSettingsStore((state) => state.graph.showEdgeLabels);
55
+
56
+ // Use appropriate path function based on settings
57
+ const getPath = () => {
58
+ const params = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition };
59
+ switch (edgeType) {
60
+ case 'straight':
61
+ return getStraightPath(params);
62
+ case 'smoothstep':
63
+ return getSmoothStepPath(params);
64
+ case 'simplebezier':
65
+ return getSimpleBezierPath(params);
66
+ case 'bezier':
67
+ default:
68
+ return getBezierPath(params);
69
+ }
70
+ };
71
+
72
+ const [edgePath, labelX, labelY] = getPath();
73
+
74
+ const edgeData = data as PendingJoinEdgeData | undefined;
75
+ const labelOffset = edgeData?.labelOffset || 0;
76
+ const waitingFor = edgeData?.waitingFor || [];
77
+
78
+ const [isHovered, setIsHovered] = React.useState(false);
79
+
80
+ // Purple color theme for JoinSpec
81
+ const edgeColor = 'var(--color-purple-500, #a855f7)';
82
+ const edgeColorHover = 'var(--color-purple-600, #9333ea)';
83
+
84
+ return (
85
+ <>
86
+ <BaseEdge
87
+ id={id}
88
+ path={edgePath}
89
+ style={{
90
+ ...style,
91
+ stroke: isHovered ? edgeColorHover : edgeColor,
92
+ strokeWidth: isHovered ? edgeStrokeWidth + 1 : edgeStrokeWidth,
93
+ strokeDasharray: '8,6', // Dashed line for "pending" state
94
+ opacity: 0.7,
95
+ transition: 'var(--transition-all)',
96
+ filter: isHovered ? `drop-shadow(0 0 6px ${edgeColor})` : 'none',
97
+ animation: 'pending-dash 2s linear infinite',
98
+ }}
99
+ markerEnd={markerEnd}
100
+ />
101
+ {label && showEdgeLabels && (
102
+ <EdgeLabelRenderer>
103
+ <div
104
+ style={{
105
+ position: 'absolute',
106
+ transform: `translate(-50%, -50%) translate(${labelX}px,${labelY + labelOffset}px)`,
107
+ fontSize: 'var(--font-size-caption)',
108
+ fontWeight: 'var(--font-weight-semibold)',
109
+ background: 'rgba(168, 85, 247, 0.12)', // Purple tinted background
110
+ color: 'var(--color-purple-700, #7e22ce)',
111
+ padding: '4px 8px',
112
+ borderRadius: 'var(--radius-sm)',
113
+ border: `1.5px dashed ${edgeColor}`,
114
+ pointerEvents: 'all',
115
+ backdropFilter: 'blur(var(--blur-sm))',
116
+ boxShadow: isHovered ? 'var(--shadow-md)' : 'var(--shadow-xs)',
117
+ transition: 'var(--transition-all)',
118
+ cursor: 'help',
119
+ }}
120
+ className="nodrag nopan"
121
+ onMouseEnter={() => setIsHovered(true)}
122
+ onMouseLeave={() => setIsHovered(false)}
123
+ title={
124
+ waitingFor.length > 0
125
+ ? `Waiting for: ${waitingFor.join(', ')}\nArtifact: ${edgeData?.artifactType || 'unknown'}`
126
+ : `Correlation pending\nArtifact: ${edgeData?.artifactType || 'unknown'}`
127
+ }
128
+ >
129
+ {label}
130
+ </div>
131
+ </EdgeLabelRenderer>
132
+ )}
133
+ <style>{`
134
+ @keyframes pending-dash {
135
+ to {
136
+ stroke-dashoffset: -28;
137
+ }
138
+ }
139
+ `}</style>
140
+ </>
141
+ );
142
+ };
143
+
144
+ export default PendingJoinEdge;
@@ -57,7 +57,8 @@ function randomPosition() {
57
57
  export function overlayWebSocketState(
58
58
  nodes: Node[],
59
59
  agentStatus: Map<string, string>,
60
- streamingTokens: Map<string, string[]>
60
+ streamingTokens: Map<string, string[]>,
61
+ agentLogicOperations?: Map<string, any[]>
61
62
  ): Node[] {
62
63
  return nodes.map(node => {
63
64
  if (node.type === 'agent') {
@@ -67,6 +68,7 @@ export function overlayWebSocketState(
67
68
  ...node.data,
68
69
  status: agentStatus.get(node.id) || node.data.status,
69
70
  streamingTokens: streamingTokens.get(node.id) || [],
71
+ logicOperations: agentLogicOperations?.get(node.id) || node.data.logicOperations || [],
70
72
  },
71
73
  };
72
74
  }
@@ -3,7 +3,7 @@ import { useGraphStore } from '../store/graphStore';
3
3
  import { useFilterStore } from '../store/filterStore';
4
4
 
5
5
  interface WebSocketMessage {
6
- event_type: 'agent_activated' | 'message_published' | 'streaming_output' | 'agent_completed' | 'agent_error';
6
+ event_type: 'agent_activated' | 'message_published' | 'streaming_output' | 'agent_completed' | 'agent_error' | 'correlation_group_updated' | 'batch_item_added';
7
7
  timestamp: string;
8
8
  correlation_id: string;
9
9
  session_id: string;
@@ -282,6 +282,104 @@ export class WebSocketClient {
282
282
  this.on('ping', () => {
283
283
  this.send({ type: 'pong', timestamp: Date.now() });
284
284
  });
285
+
286
+ // Phase 1.3: Handler for correlation_group_updated - update logic operations state
287
+ this.on('correlation_group_updated', (data) => {
288
+ const { agent_name, subscription_index, correlation_key, elapsed_seconds, expires_in_seconds, waiting_for } = data;
289
+
290
+ console.log('[WebSocket] Correlation group updated:', {
291
+ agent: agent_name,
292
+ key: correlation_key,
293
+ waiting_for,
294
+ elapsed: elapsed_seconds,
295
+ expires_in: expires_in_seconds,
296
+ });
297
+
298
+ // Get current logic operations for this agent
299
+ const graphStore = useGraphStore.getState();
300
+ const currentLogicOps = graphStore.agentLogicOperations.get(agent_name) || [];
301
+
302
+ // Find the subscription's logic operations
303
+ const updatedLogicOps = currentLogicOps.map((logicOp) => {
304
+ if (logicOp.subscription_index === subscription_index && logicOp.join) {
305
+ // Update waiting_state with correlation group data
306
+ const correlationGroup = {
307
+ correlation_key: data.correlation_key,
308
+ created_at: data.timestamp,
309
+ elapsed_seconds: data.elapsed_seconds,
310
+ expires_in_seconds: data.expires_in_seconds,
311
+ expires_in_artifacts: data.expires_in_artifacts,
312
+ collected_types: data.collected_types,
313
+ required_types: data.required_types,
314
+ waiting_for: data.waiting_for,
315
+ is_complete: data.is_complete,
316
+ is_expired: false,
317
+ };
318
+
319
+ return {
320
+ ...logicOp,
321
+ waiting_state: {
322
+ is_waiting: true,
323
+ correlation_groups: [correlationGroup],
324
+ },
325
+ };
326
+ }
327
+ return logicOp;
328
+ });
329
+
330
+ // Update graph store with new logic operations state
331
+ if (updatedLogicOps.length > 0) {
332
+ graphStore.updateAgentLogicOperations(agent_name, updatedLogicOps);
333
+ }
334
+ });
335
+
336
+ // Phase 1.3: Handler for batch_item_added - update logic operations state
337
+ this.on('batch_item_added', (data) => {
338
+ const { agent_name, subscription_index, items_collected, items_target, timeout_remaining_seconds, will_flush } = data;
339
+
340
+ console.log('[WebSocket] Batch item added:', {
341
+ agent: agent_name,
342
+ collected: items_collected,
343
+ target: items_target,
344
+ timeout_remaining: timeout_remaining_seconds,
345
+ will_flush,
346
+ });
347
+
348
+ // Get current logic operations for this agent
349
+ const graphStore = useGraphStore.getState();
350
+ const currentLogicOps = graphStore.agentLogicOperations.get(agent_name) || [];
351
+
352
+ // Find the subscription's logic operations
353
+ const updatedLogicOps = currentLogicOps.map((logicOp) => {
354
+ if (logicOp.subscription_index === subscription_index && logicOp.batch) {
355
+ // Update waiting_state with batch data
356
+ const batchState = {
357
+ created_at: data.timestamp,
358
+ elapsed_seconds: data.elapsed_seconds,
359
+ items_collected: data.items_collected,
360
+ items_target: data.items_target,
361
+ items_remaining: data.items_remaining,
362
+ timeout_seconds: data.timeout_seconds,
363
+ timeout_remaining_seconds: data.timeout_remaining_seconds,
364
+ will_flush: data.will_flush,
365
+ };
366
+
367
+ return {
368
+ ...logicOp,
369
+ waiting_state: {
370
+ is_waiting: true,
371
+ batch_state: batchState,
372
+ },
373
+ };
374
+ }
375
+ return logicOp;
376
+ });
377
+
378
+ // Update graph store with new logic operations state
379
+ if (updatedLogicOps.length > 0) {
380
+ graphStore.updateAgentLogicOperations(agent_name, updatedLogicOps);
381
+ }
382
+ });
285
383
  }
286
384
 
287
385
  connect(): void {
@@ -468,7 +468,8 @@ describe('graphStore - NEW Simplified Architecture', () => {
468
468
  expect(overlayWebSocketState).toHaveBeenCalledWith(
469
469
  mockMergedNodes,
470
470
  expect.any(Map), // agentStatus
471
- expect.any(Map) // streamingTokens
471
+ expect.any(Map), // streamingTokens
472
+ expect.any(Map) // agentLogicOperations
472
473
  );
473
474
  });
474
475
  });
@@ -1,7 +1,7 @@
1
1
  import { create } from 'zustand';
2
2
  import { devtools } from 'zustand/middleware';
3
3
  import { Node, Edge } from '@xyflow/react';
4
- import { GraphSnapshot, GraphStatistics, GraphRequest } from '../types/graph';
4
+ import { GraphSnapshot, GraphStatistics, GraphRequest, AgentLogicOperations } from '../types/graph';
5
5
  import { fetchGraphSnapshot, mergeNodePositions, overlayWebSocketState } from '../services/graphService';
6
6
  import { useFilterStore } from './filterStore';
7
7
  import { Message } from '../types/graph';
@@ -20,12 +20,17 @@ import { indexedDBService } from '../services/indexeddb';
20
20
  * - Debounced refresh: 100ms batching for snappy UX
21
21
  * - No more client-side edge derivation
22
22
  * - No more synthetic runs or complex Maps
23
+ *
24
+ * Phase 1.3: Logic Operations UX
25
+ * - Added logic operations state for JoinSpec/BatchSpec waiting states
26
+ * - Real-time updates via CorrelationGroupUpdatedEvent and BatchItemAddedEvent
23
27
  */
24
28
 
25
29
  interface GraphState {
26
30
  // Real-time WebSocket state (overlaid on backend snapshot)
27
31
  agentStatus: Map<string, string>;
28
32
  streamingTokens: Map<string, string[]>;
33
+ agentLogicOperations: Map<string, AgentLogicOperations[]>; // Phase 1.3: Logic operations state
29
34
 
30
35
  // Backend snapshot state
31
36
  nodes: Node[];
@@ -52,6 +57,7 @@ interface GraphState {
52
57
  // Actions - Real-time WebSocket updates
53
58
  updateAgentStatus: (agentId: string, status: string) => void;
54
59
  updateStreamingTokens: (agentId: string, tokens: string[]) => void;
60
+ updateAgentLogicOperations: (agentId: string, logicOps: AgentLogicOperations[]) => void; // Phase 1.3
55
61
  addEvent: (message: Message) => void;
56
62
 
57
63
  // Actions - Streaming message nodes (Phase 6)
@@ -118,6 +124,7 @@ export const useGraphStore = create<GraphState>()(
118
124
  // Initial state
119
125
  agentStatus: new Map(),
120
126
  streamingTokens: new Map(),
127
+ agentLogicOperations: new Map(), // Phase 1.3
121
128
  nodes: [],
122
129
  edges: [],
123
130
  statistics: null,
@@ -138,13 +145,13 @@ export const useGraphStore = create<GraphState>()(
138
145
  const request = buildGraphRequest('agent');
139
146
  const snapshot: GraphSnapshot = await fetchGraphSnapshot(request);
140
147
 
141
- const { savedPositions, nodes: currentNodes, agentStatus, streamingTokens } = get();
148
+ const { savedPositions, nodes: currentNodes, agentStatus, streamingTokens, agentLogicOperations } = get();
142
149
 
143
150
  // Merge positions: saved > current > backend > random
144
151
  const mergedNodes = mergeNodePositions(snapshot.nodes, savedPositions, currentNodes);
145
152
 
146
153
  // Overlay real-time WebSocket state
147
- const finalNodes = overlayWebSocketState(mergedNodes, agentStatus, streamingTokens);
154
+ const finalNodes = overlayWebSocketState(mergedNodes, agentStatus, streamingTokens, agentLogicOperations);
148
155
 
149
156
  set({
150
157
  nodes: finalNodes,
@@ -193,13 +200,13 @@ export const useGraphStore = create<GraphState>()(
193
200
  const request = buildGraphRequest('blackboard');
194
201
  const snapshot: GraphSnapshot = await fetchGraphSnapshot(request);
195
202
 
196
- const { savedPositions, nodes: currentNodes, agentStatus, streamingTokens } = get();
203
+ const { savedPositions, nodes: currentNodes, agentStatus, streamingTokens, agentLogicOperations } = get();
197
204
 
198
205
  // Merge positions: saved > current > backend > random
199
206
  const mergedNodes = mergeNodePositions(snapshot.nodes, savedPositions, currentNodes);
200
207
 
201
208
  // Overlay real-time WebSocket state (primarily for message streaming)
202
- const finalNodes = overlayWebSocketState(mergedNodes, agentStatus, streamingTokens);
209
+ const finalNodes = overlayWebSocketState(mergedNodes, agentStatus, streamingTokens, agentLogicOperations);
203
210
 
204
211
  set({
205
212
  nodes: finalNodes,
@@ -309,6 +316,30 @@ export const useGraphStore = create<GraphState>()(
309
316
  });
310
317
  },
311
318
 
319
+ // Phase 1.3: Logic Operations UX - Update agent logic operations state
320
+ updateAgentLogicOperations: (agentId, logicOps) => {
321
+ set((state) => {
322
+ const agentLogicOperations = new Map(state.agentLogicOperations);
323
+ agentLogicOperations.set(agentId, logicOps);
324
+
325
+ // Update agent nodes with logic operations data
326
+ const nodes = state.nodes.map(node => {
327
+ if (node.type === 'agent' && node.id === agentId) {
328
+ return {
329
+ ...node,
330
+ data: {
331
+ ...node.data,
332
+ logicOperations: logicOps,
333
+ },
334
+ };
335
+ }
336
+ return node;
337
+ });
338
+
339
+ return { agentLogicOperations, nodes };
340
+ });
341
+ },
342
+
312
343
  addEvent: (message) => {
313
344
  set((state) => {
314
345
  // Add to events array (max 100 items)
@@ -93,3 +93,89 @@ export interface ArtifactSummary {
93
93
  earliest_created_at: string;
94
94
  latest_created_at: string;
95
95
  }
96
+
97
+ // Phase 1.3: Logic Operations UX - Real-time WebSocket Events
98
+ export interface CorrelationGroupUpdatedEvent {
99
+ timestamp: string;
100
+ agent_name: string;
101
+ subscription_index: number;
102
+ correlation_key: string;
103
+ collected_types: Record<string, number>;
104
+ required_types: Record<string, number>;
105
+ waiting_for: string[];
106
+ elapsed_seconds: number;
107
+ expires_in_seconds: number | null;
108
+ expires_in_artifacts: number | null;
109
+ artifact_id: string;
110
+ artifact_type: string;
111
+ is_complete: boolean;
112
+ }
113
+
114
+ export interface BatchItemAddedEvent {
115
+ timestamp: string;
116
+ agent_name: string;
117
+ subscription_index: number;
118
+ items_collected: number;
119
+ items_target: number | null;
120
+ items_remaining: number | null;
121
+ elapsed_seconds: number;
122
+ timeout_seconds: number | null;
123
+ timeout_remaining_seconds: number | null;
124
+ will_flush: 'on_size' | 'on_timeout' | 'unknown';
125
+ artifact_id: string;
126
+ artifact_type: string;
127
+ }
128
+
129
+ // Agent logic operations state (from /api/agents endpoint + WebSocket updates)
130
+ export interface AgentLogicOperations {
131
+ subscription_index: number;
132
+ subscription_types: string[];
133
+ join?: JoinSpecConfig;
134
+ batch?: BatchSpecConfig;
135
+ waiting_state?: LogicOperationsWaitingState;
136
+ }
137
+
138
+ export interface JoinSpecConfig {
139
+ correlation_strategy: 'by_key';
140
+ window_type: 'time' | 'count';
141
+ window_value: number;
142
+ window_unit: 'seconds' | 'artifacts';
143
+ required_types: string[];
144
+ type_counts: Record<string, number>;
145
+ }
146
+
147
+ export interface BatchSpecConfig {
148
+ strategy: 'size' | 'timeout' | 'hybrid';
149
+ size?: number;
150
+ timeout_seconds?: number;
151
+ }
152
+
153
+ export interface LogicOperationsWaitingState {
154
+ is_waiting: boolean;
155
+ correlation_groups?: CorrelationGroupState[];
156
+ batch_state?: BatchState;
157
+ }
158
+
159
+ export interface CorrelationGroupState {
160
+ correlation_key: string;
161
+ created_at: string;
162
+ elapsed_seconds: number;
163
+ expires_in_seconds: number | null;
164
+ expires_in_artifacts: number | null;
165
+ collected_types: Record<string, number>;
166
+ required_types: Record<string, number>;
167
+ waiting_for: string[];
168
+ is_complete: boolean;
169
+ is_expired: boolean;
170
+ }
171
+
172
+ export interface BatchState {
173
+ created_at: string;
174
+ elapsed_seconds: number;
175
+ items_collected: number;
176
+ items_target: number | null;
177
+ items_remaining: number | null;
178
+ timeout_seconds?: number;
179
+ timeout_remaining_seconds?: number;
180
+ will_flush: 'on_size' | 'on_timeout' | 'unknown';
181
+ }
flock/orchestrator.py CHANGED
@@ -137,6 +137,8 @@ class Flock(metaclass=AutoTracedMeta):
137
137
  self._correlation_engine = CorrelationEngine()
138
138
  # BatchSpec logic: Batch accumulator for size/timeout batching
139
139
  self._batch_engine = BatchEngine()
140
+ # Phase 1.2: WebSocket manager for real-time dashboard events (set by serve())
141
+ self._websocket_manager: Any = None
140
142
  # Unified tracing support
141
143
  self._workflow_span = None
142
144
  self._auto_workflow_enabled = os.getenv("FLOCK_AUTO_WORKFLOW_TRACE", "false").lower() in {
@@ -602,6 +604,8 @@ class Flock(metaclass=AutoTracedMeta):
602
604
 
603
605
  # Store collector reference for agents added later
604
606
  self._dashboard_collector = event_collector
607
+ # Store websocket manager for real-time event emission (Phase 1.2)
608
+ self._websocket_manager = websocket_manager
605
609
 
606
610
  # Inject event collector into all existing agents
607
611
  for agent in self._agents.values():
@@ -907,6 +911,12 @@ class Flock(metaclass=AutoTracedMeta):
907
911
 
908
912
  if completed_group is None:
909
913
  # Still waiting for correlation to complete
914
+ # Phase 1.2: Emit real-time correlation update event
915
+ await self._emit_correlation_updated_event(
916
+ agent_name=agent.name,
917
+ subscription_index=subscription_index,
918
+ artifact=artifact,
919
+ )
910
920
  continue
911
921
 
912
922
  # Correlation complete! Get all correlated artifacts
@@ -954,6 +964,13 @@ class Flock(metaclass=AutoTracedMeta):
954
964
 
955
965
  if not should_flush:
956
966
  # Batch not full yet - wait for more artifacts
967
+ # Phase 1.2: Emit real-time batch update event
968
+ await self._emit_batch_item_added_event(
969
+ agent_name=agent.name,
970
+ subscription_index=subscription_index,
971
+ subscription=subscription,
972
+ artifact=artifact,
973
+ )
957
974
  continue
958
975
 
959
976
  # Flush the batch and get all accumulated artifacts
@@ -1026,6 +1043,115 @@ class Flock(metaclass=AutoTracedMeta):
1026
1043
  except Exception as exc: # pragma: no cover - defensive logging
1027
1044
  self._logger.exception("Failed to record artifact consumption: %s", exc)
1028
1045
 
1046
+ # Phase 1.2: Logic Operations Event Emission ----------------------------
1047
+
1048
+ async def _emit_correlation_updated_event(
1049
+ self, *, agent_name: str, subscription_index: int, artifact: Artifact
1050
+ ) -> None:
1051
+ """Emit CorrelationGroupUpdatedEvent for real-time dashboard updates.
1052
+
1053
+ Called when an artifact is added to a correlation group that is not yet complete.
1054
+
1055
+ Args:
1056
+ agent_name: Name of the agent with the JoinSpec subscription
1057
+ subscription_index: Index of the subscription in the agent's subscriptions list
1058
+ artifact: The artifact that triggered this update
1059
+ """
1060
+ # Only emit if dashboard is enabled
1061
+ if self._websocket_manager is None:
1062
+ return
1063
+
1064
+ # Import _get_correlation_groups helper from dashboard service
1065
+ from flock.dashboard.service import _get_correlation_groups
1066
+
1067
+ # Get current correlation groups state from engine
1068
+ groups = _get_correlation_groups(self._correlation_engine, agent_name, subscription_index)
1069
+
1070
+ if not groups:
1071
+ return # No groups to report (shouldn't happen, but defensive)
1072
+
1073
+ # Find the group that was just updated (match by last updated time or artifact ID)
1074
+ # For now, we'll emit an event for the FIRST group that's still waiting
1075
+ # In practice, the artifact we just added should be in one of these groups
1076
+ for group_state in groups:
1077
+ if not group_state["is_complete"]:
1078
+ # Import CorrelationGroupUpdatedEvent
1079
+ from flock.dashboard.events import CorrelationGroupUpdatedEvent
1080
+
1081
+ # Build and emit event
1082
+ event = CorrelationGroupUpdatedEvent(
1083
+ agent_name=agent_name,
1084
+ subscription_index=subscription_index,
1085
+ correlation_key=group_state["correlation_key"],
1086
+ collected_types=group_state["collected_types"],
1087
+ required_types=group_state["required_types"],
1088
+ waiting_for=group_state["waiting_for"],
1089
+ elapsed_seconds=group_state["elapsed_seconds"],
1090
+ expires_in_seconds=group_state["expires_in_seconds"],
1091
+ expires_in_artifacts=group_state["expires_in_artifacts"],
1092
+ artifact_id=str(artifact.id),
1093
+ artifact_type=artifact.type,
1094
+ is_complete=group_state["is_complete"],
1095
+ )
1096
+
1097
+ # Broadcast via WebSocket
1098
+ await self._websocket_manager.broadcast(event)
1099
+ break # Only emit one event per artifact addition
1100
+
1101
+ async def _emit_batch_item_added_event(
1102
+ self,
1103
+ *,
1104
+ agent_name: str,
1105
+ subscription_index: int,
1106
+ subscription: Subscription, # noqa: F821
1107
+ artifact: Artifact,
1108
+ ) -> None:
1109
+ """Emit BatchItemAddedEvent for real-time dashboard updates.
1110
+
1111
+ Called when an artifact is added to a batch that hasn't reached flush threshold.
1112
+
1113
+ Args:
1114
+ agent_name: Name of the agent with the BatchSpec subscription
1115
+ subscription_index: Index of the subscription in the agent's subscriptions list
1116
+ subscription: The subscription with BatchSpec configuration
1117
+ artifact: The artifact that triggered this update
1118
+ """
1119
+ # Only emit if dashboard is enabled
1120
+ if self._websocket_manager is None:
1121
+ return
1122
+
1123
+ # Import _get_batch_state helper from dashboard service
1124
+ from flock.dashboard.service import _get_batch_state
1125
+
1126
+ # Get current batch state from engine
1127
+ batch_state = _get_batch_state(
1128
+ self._batch_engine, agent_name, subscription_index, subscription.batch
1129
+ )
1130
+
1131
+ if not batch_state:
1132
+ return # No batch to report (shouldn't happen, but defensive)
1133
+
1134
+ # Import BatchItemAddedEvent
1135
+ from flock.dashboard.events import BatchItemAddedEvent
1136
+
1137
+ # Build and emit event
1138
+ event = BatchItemAddedEvent(
1139
+ agent_name=agent_name,
1140
+ subscription_index=subscription_index,
1141
+ items_collected=batch_state["items_collected"],
1142
+ items_target=batch_state.get("items_target"),
1143
+ items_remaining=batch_state.get("items_remaining"),
1144
+ elapsed_seconds=batch_state["elapsed_seconds"],
1145
+ timeout_seconds=batch_state.get("timeout_seconds"),
1146
+ timeout_remaining_seconds=batch_state.get("timeout_remaining_seconds"),
1147
+ will_flush=batch_state["will_flush"],
1148
+ artifact_id=str(artifact.id),
1149
+ artifact_type=artifact.type,
1150
+ )
1151
+
1152
+ # Broadcast via WebSocket
1153
+ await self._websocket_manager.broadcast(event)
1154
+
1029
1155
  # Batch Helpers --------------------------------------------------------
1030
1156
 
1031
1157
  async def _check_batch_timeouts(self) -> None:
@@ -1055,7 +1181,7 @@ class Flock(metaclass=AutoTracedMeta):
1055
1181
  """Flush all partial batches (for shutdown - ensures zero data loss)."""
1056
1182
  all_batches = self._batch_engine.flush_all()
1057
1183
 
1058
- for agent_name, subscription_index, artifacts in all_batches:
1184
+ for agent_name, _subscription_index, artifacts in all_batches:
1059
1185
  # Get the agent
1060
1186
  agent = self._agents.get(agent_name)
1061
1187
  if agent is None:
flock/patches/__init__.py CHANGED
@@ -2,4 +2,5 @@
2
2
 
3
3
  from flock.patches.dspy_streaming_patch import apply_patch, restore_original
4
4
 
5
+
5
6
  __all__ = ["apply_patch", "restore_original"]
@@ -10,6 +10,7 @@ This patch replaces it with a non-blocking fire-and-forget approach.
10
10
  import asyncio
11
11
  import logging
12
12
 
13
+
13
14
  logger = logging.getLogger(__name__)
14
15
 
15
16