agentgui 1.0.645 → 1.0.646
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.
- package/SYNC_ARCHITECTURE.md +279 -0
- package/docs/SYNC_TO_DISPLAY_REPORT.md +279 -0
- package/lib/ws-handlers-conv.js +20 -12
- package/package.json +1 -1
- package/server.js +10 -1
- package/static/js/client.js +68 -2
- package/static/js/sync-debug.js +148 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# AgentGUI Sync-to-Display Architecture Report
|
|
2
|
+
|
|
3
|
+
## Executive Summary
|
|
4
|
+
|
|
5
|
+
The sync-to-display system handles real-time synchronization of execution state from server to client display. Current implementation has been improved through multiple fixes, particularly for queue display consistency and steering support. This document consolidates the architecture and identifies remaining optimization opportunities.
|
|
6
|
+
|
|
7
|
+
## Architecture Overview
|
|
8
|
+
|
|
9
|
+
### 1. Broadcast System (server.js:4269-4317)
|
|
10
|
+
|
|
11
|
+
**BROADCAST_TYPES** - Global broadcast events sent to ALL connected clients:
|
|
12
|
+
- `message_created`, `conversation_created/updated/deleted`
|
|
13
|
+
- `queue_status`, `queue_updated`
|
|
14
|
+
- `streaming_start`, `streaming_progress`, `streaming_complete`, `streaming_error`
|
|
15
|
+
- `rate_limit_hit`, `rate_limit_clear`
|
|
16
|
+
- Tool-related events (`tool_*`, `tools_*`)
|
|
17
|
+
- Voice/speech events
|
|
18
|
+
- PM2 monitoring events
|
|
19
|
+
|
|
20
|
+
**broadcastSync()** function (line 4288):
|
|
21
|
+
```
|
|
22
|
+
1. Check if event.type is in BROADCAST_TYPES
|
|
23
|
+
2. If broadcast: send to ALL syncClients
|
|
24
|
+
3. If targeted: send to sessionId or conversationId subscribers only
|
|
25
|
+
4. Also dispatch to SSE stream handlers if active
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### 2. Client-Side Message Reception (static/js/client.js)
|
|
29
|
+
|
|
30
|
+
**handleWsMessage()** routes events by type:
|
|
31
|
+
```javascript
|
|
32
|
+
case 'message_created': handleMessageCreated(data)
|
|
33
|
+
case 'queue_status': handleQueueStatus(data)
|
|
34
|
+
case 'streaming_start': handleStreamingStart(data)
|
|
35
|
+
case 'streaming_progress': handleStreamingProgress(data)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### 3. Display Rendering Layers
|
|
39
|
+
|
|
40
|
+
**Layer 1: Message Rendering** (handleMessageCreated)
|
|
41
|
+
- Checks if message already exists (prevents duplicates)
|
|
42
|
+
- For user messages: finds optimistic message, updates it
|
|
43
|
+
- For assistant messages: skips if streaming (handled by chunks)
|
|
44
|
+
|
|
45
|
+
**Layer 2: Streaming Progress** (handleStreamingProgress)
|
|
46
|
+
- Adds chunks to queue
|
|
47
|
+
- Batches rendering via StreamingRenderer
|
|
48
|
+
- Handles thinking blocks directly
|
|
49
|
+
|
|
50
|
+
**Layer 3: Queue Indicator** (fetchAndRenderQueue)
|
|
51
|
+
- Polls via `q.ls` RPC every interval
|
|
52
|
+
- Displays queued items in yellow control blocks
|
|
53
|
+
- Renders with Edit/Delete/Steer buttons
|
|
54
|
+
|
|
55
|
+
**Layer 4: Conversation State** (handleConversationUpdated)
|
|
56
|
+
- Updates isStreaming flag
|
|
57
|
+
- Refreshes UI based on new state
|
|
58
|
+
|
|
59
|
+
## Recent Fixes & Status
|
|
60
|
+
|
|
61
|
+
### Fixed Issues
|
|
62
|
+
|
|
63
|
+
✅ **Queue Message Duplication** (commit fbfd1ad)
|
|
64
|
+
- Problem: Queued messages appeared as both user prompt + queue item
|
|
65
|
+
- Solution: Skip optimistic message when conversation is streaming
|
|
66
|
+
- Impact: Clean queue display, no duplication
|
|
67
|
+
|
|
68
|
+
✅ **Steering Support** (commit 81d83af)
|
|
69
|
+
- Problem: Steering showed "Process not available" error
|
|
70
|
+
- Solution: Keep stdin open (supportsStdin=true, closeStdin=false)
|
|
71
|
+
- Impact: Users can send follow-up prompts during execution
|
|
72
|
+
|
|
73
|
+
✅ **Chunk Rendering Race** (Session 3a)
|
|
74
|
+
- Problem: Early chunks missed before polling started
|
|
75
|
+
- Solution: Immediate chunk fetch on streaming_start
|
|
76
|
+
- Impact: No missing initial output
|
|
77
|
+
|
|
78
|
+
✅ **Dark Mode Selection** (fsbrowse)
|
|
79
|
+
- Problem: Selection states hard to see in dark mode
|
|
80
|
+
- Solution: Theme-aware colors for hover/focus
|
|
81
|
+
- Impact: Better visual feedback in both themes
|
|
82
|
+
|
|
83
|
+
### Remaining Consistency Issues
|
|
84
|
+
|
|
85
|
+
⚠️ **Queue State Sync Timing**
|
|
86
|
+
- Queue created on server via enqueue()
|
|
87
|
+
- message_created broadcast may arrive before queue is populated
|
|
88
|
+
- Client fetchAndRenderQueue() polls but timing is unpredictable
|
|
89
|
+
- No atomic operation ensuring client sees both message and queue
|
|
90
|
+
|
|
91
|
+
⚠️ **Multiple Sources of Truth**
|
|
92
|
+
- Server: database, active execution map, queue map
|
|
93
|
+
- Client: message list, streaming set, queue indicator
|
|
94
|
+
- Conversation state: isStreaming flag can desync if connection drops
|
|
95
|
+
- No single authoritative state object
|
|
96
|
+
|
|
97
|
+
⚠️ **Optimistic Message Updates**
|
|
98
|
+
- Client creates optimistic "sending" message on submit
|
|
99
|
+
- Server creates actual message and broadcasts
|
|
100
|
+
- handleMessageCreated() finds and updates optimistic message
|
|
101
|
+
- If network is slow, both may briefly exist
|
|
102
|
+
|
|
103
|
+
⚠️ **Queue Execution Transition**
|
|
104
|
+
- Queue items don't explicitly transition to executed
|
|
105
|
+
- Client must poll queue indicator to see it disappear
|
|
106
|
+
- No event indicating "queue item now executing"
|
|
107
|
+
- Confusing UX when queue suddenly empties
|
|
108
|
+
|
|
109
|
+
## Data Flow Examples
|
|
110
|
+
|
|
111
|
+
### Normal Execution (Ctrl+Enter with no active stream)
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
User input: "Write a function"
|
|
115
|
+
↓
|
|
116
|
+
startExecution()
|
|
117
|
+
→ _showOptimisticMessage(pendingId, content) // yellow "sending..." message
|
|
118
|
+
↓
|
|
119
|
+
streamToConversation() → msg.stream RPC
|
|
120
|
+
↓
|
|
121
|
+
Server: msg.stream handler
|
|
122
|
+
→ createMessage(p.id, 'user', prompt)
|
|
123
|
+
→ broadcastSync({ type: 'message_created', ... })
|
|
124
|
+
→ startExecution() -> streaming_start broadcast
|
|
125
|
+
↓
|
|
126
|
+
Client: message_created event
|
|
127
|
+
→ handleMessageCreated()
|
|
128
|
+
→ Finds optimistic message by ID
|
|
129
|
+
→ Updates it (removes "sending" style, adds timestamp)
|
|
130
|
+
↓
|
|
131
|
+
Client: streaming_start event
|
|
132
|
+
→ Disables input, shows "thinking..."
|
|
133
|
+
→ Subscribes to session
|
|
134
|
+
→ Starts chunk polling
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Queue Case (Ctrl+Enter while streaming) - NOW FIXED
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
User input: "Now add subtraction" during execution
|
|
141
|
+
↓
|
|
142
|
+
startExecution()
|
|
143
|
+
→ isStreaming = true
|
|
144
|
+
→ SKIP _showOptimisticMessage ✓ (FIXED)
|
|
145
|
+
↓
|
|
146
|
+
streamToConversation() → msg.stream RPC
|
|
147
|
+
↓
|
|
148
|
+
Server: msg.stream handler
|
|
149
|
+
→ createMessage(p.id, 'user', prompt)
|
|
150
|
+
→ broadcastSync({ type: 'message_created', ... })
|
|
151
|
+
→ activeExecutions.has(p.id) = true
|
|
152
|
+
→ enqueue(p.id, prompt, ...)
|
|
153
|
+
→ broadcastSync({ type: 'queue_status', ... })
|
|
154
|
+
↓
|
|
155
|
+
Client: message_created event
|
|
156
|
+
→ handleMessageCreated()
|
|
157
|
+
→ Message is not in current conversation display
|
|
158
|
+
→ Emit event but don't render
|
|
159
|
+
↓
|
|
160
|
+
Client: queue_status event
|
|
161
|
+
→ handleQueueStatus()
|
|
162
|
+
→ fetchAndRenderQueue()
|
|
163
|
+
→ Displays in yellow queue indicator ✓
|
|
164
|
+
↓
|
|
165
|
+
Result: Message only in queue, no duplication ✓
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Steering (Ctrl+Enter + Steer button during execution)
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
User clicks Steer button on queued message
|
|
172
|
+
↓
|
|
173
|
+
Client: conv.steer RPC with JSON-RPC format
|
|
174
|
+
↓
|
|
175
|
+
Server: conv.steer handler
|
|
176
|
+
→ Finds active process stdin
|
|
177
|
+
→ Writes JSON-RPC prompt request
|
|
178
|
+
→ Removes from queue (optional)
|
|
179
|
+
↓
|
|
180
|
+
Claude Code: Receives JSON-RPC on stdin
|
|
181
|
+
→ Processes prompt immediately
|
|
182
|
+
→ Continues execution with new direction
|
|
183
|
+
↓
|
|
184
|
+
Client: receives streaming_progress chunks
|
|
185
|
+
→ Renders new content below existing output
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Consistency Guarantees
|
|
189
|
+
|
|
190
|
+
### What IS Consistent
|
|
191
|
+
- Messages don't duplicate (handled in handleMessageCreated)
|
|
192
|
+
- Streaming chunks are ordered (via session ID subscription)
|
|
193
|
+
- Queue items maintain order (FIFO in server queue map)
|
|
194
|
+
- Tool installations tracked atomically (per-tool in database)
|
|
195
|
+
|
|
196
|
+
### What IS NOT Guaranteed
|
|
197
|
+
- Client queue display and server queue state may briefly desync
|
|
198
|
+
- If connection drops mid-queue, client state becomes stale
|
|
199
|
+
- No explicit confirmation that user message was queued
|
|
200
|
+
- Queue item execution not signaled with explicit event
|
|
201
|
+
|
|
202
|
+
## Recommendations for Further Improvement
|
|
203
|
+
|
|
204
|
+
### Priority 1: Explicit Queue Lifecycle
|
|
205
|
+
Add dedicated broadcast events:
|
|
206
|
+
```javascript
|
|
207
|
+
'message_queued' // Sent when msg added to queue
|
|
208
|
+
'queue_item_executing' // Sent when queue item becomes active
|
|
209
|
+
'queue_item_completed' // Sent when queue item finished
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Priority 2: Atomic State Snapshots
|
|
213
|
+
Periodically broadcast conversation state:
|
|
214
|
+
```javascript
|
|
215
|
+
{
|
|
216
|
+
type: 'conversation_state',
|
|
217
|
+
conversationId,
|
|
218
|
+
state: {
|
|
219
|
+
isStreaming: boolean,
|
|
220
|
+
messageCount: number,
|
|
221
|
+
queueLength: number,
|
|
222
|
+
lastMessageId: string,
|
|
223
|
+
lastUpdate: timestamp
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Priority 3: Deterministic Client State Machine
|
|
229
|
+
Each conversation has explicit state mode:
|
|
230
|
+
```javascript
|
|
231
|
+
state = {
|
|
232
|
+
mode: 'IDLE' | 'STREAMING' | 'QUEUED',
|
|
233
|
+
messages: [],
|
|
234
|
+
queue: [],
|
|
235
|
+
isTransitioning: boolean // true when queue→execute
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Priority 4: Connection Recovery
|
|
240
|
+
After reconnect, fetch complete conversation state:
|
|
241
|
+
```javascript
|
|
242
|
+
await wsClient.rpc('conv.sync', { id: conversationId })
|
|
243
|
+
// Returns: { messages, queue, isStreaming, chunks }
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Testing Matrix
|
|
247
|
+
|
|
248
|
+
| Scenario | Before Fix | After Fix | Status |
|
|
249
|
+
|----------|-----------|-----------|--------|
|
|
250
|
+
| Ctrl+Enter (not streaming) | ✓ Works | ✓ Works | ✓ OK |
|
|
251
|
+
| Ctrl+Enter (streaming) | ✗ Duplicate | ✓ Single queue item | ✓ FIXED |
|
|
252
|
+
| Queue indicator updates | ⚠️ Polling | ⚠️ Polling | ⚠️ Could improve |
|
|
253
|
+
| Steering works | ✗ "Not available" | ✓ Works | ✓ FIXED |
|
|
254
|
+
| Multiple queued items | ⚠️ Order unclear | ⚠️ Order unclear | ⚠️ OK but could confirm |
|
|
255
|
+
| Page reload with queue | ✗ Queue lost | ✓ Persisted | ✓ OK |
|
|
256
|
+
| Network disconnect | ⚠️ State stale | ⚠️ State stale | ⚠️ Could improve |
|
|
257
|
+
|
|
258
|
+
## Files Modified in Latest Session
|
|
259
|
+
|
|
260
|
+
1. **lib/claude-runner.js** - Steering support
|
|
261
|
+
- Changed `supportsStdin: false` → `true`
|
|
262
|
+
- Changed `closeStdin: true` → `false`
|
|
263
|
+
- Removed positional prompt argument
|
|
264
|
+
|
|
265
|
+
2. **static/js/client.js** - Queue display fix
|
|
266
|
+
- Skip optimistic message when streaming
|
|
267
|
+
- Skip confirm/fail handlers for queued messages
|
|
268
|
+
|
|
269
|
+
3. **static/index.html** - Hamburger animation
|
|
270
|
+
- Added `transition: none` to .sidebar
|
|
271
|
+
|
|
272
|
+
4. **fsbrowse/public/style.css** - Dark mode colors
|
|
273
|
+
- Improved hover states
|
|
274
|
+
- Theme-aware modal focus colors
|
|
275
|
+
|
|
276
|
+
## Conclusion
|
|
277
|
+
|
|
278
|
+
The sync-to-display system is now more consistent with the queue display fix and steering support. The architecture handles the primary use cases well, but could benefit from explicit lifecycle events and atomic state snapshots for guaranteed consistency. The current implementation is pragmatic and performant, trading off some guarantees for simplicity and responsiveness.
|
|
279
|
+
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# AgentGUI Sync-to-Display Architecture Report
|
|
2
|
+
|
|
3
|
+
## Executive Summary
|
|
4
|
+
|
|
5
|
+
The sync-to-display system handles real-time synchronization of execution state from server to client display. Current implementation has been improved through multiple fixes, particularly for queue display consistency and steering support. This document consolidates the architecture and identifies remaining optimization opportunities.
|
|
6
|
+
|
|
7
|
+
## Architecture Overview
|
|
8
|
+
|
|
9
|
+
### 1. Broadcast System (server.js:4269-4317)
|
|
10
|
+
|
|
11
|
+
**BROADCAST_TYPES** - Global broadcast events sent to ALL connected clients:
|
|
12
|
+
- `message_created`, `conversation_created/updated/deleted`
|
|
13
|
+
- `queue_status`, `queue_updated`
|
|
14
|
+
- `streaming_start`, `streaming_progress`, `streaming_complete`, `streaming_error`
|
|
15
|
+
- `rate_limit_hit`, `rate_limit_clear`
|
|
16
|
+
- Tool-related events (`tool_*`, `tools_*`)
|
|
17
|
+
- Voice/speech events
|
|
18
|
+
- PM2 monitoring events
|
|
19
|
+
|
|
20
|
+
**broadcastSync()** function (line 4288):
|
|
21
|
+
```
|
|
22
|
+
1. Check if event.type is in BROADCAST_TYPES
|
|
23
|
+
2. If broadcast: send to ALL syncClients
|
|
24
|
+
3. If targeted: send to sessionId or conversationId subscribers only
|
|
25
|
+
4. Also dispatch to SSE stream handlers if active
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### 2. Client-Side Message Reception (static/js/client.js)
|
|
29
|
+
|
|
30
|
+
**handleWsMessage()** routes events by type:
|
|
31
|
+
```javascript
|
|
32
|
+
case 'message_created': handleMessageCreated(data)
|
|
33
|
+
case 'queue_status': handleQueueStatus(data)
|
|
34
|
+
case 'streaming_start': handleStreamingStart(data)
|
|
35
|
+
case 'streaming_progress': handleStreamingProgress(data)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### 3. Display Rendering Layers
|
|
39
|
+
|
|
40
|
+
**Layer 1: Message Rendering** (handleMessageCreated)
|
|
41
|
+
- Checks if message already exists (prevents duplicates)
|
|
42
|
+
- For user messages: finds optimistic message, updates it
|
|
43
|
+
- For assistant messages: skips if streaming (handled by chunks)
|
|
44
|
+
|
|
45
|
+
**Layer 2: Streaming Progress** (handleStreamingProgress)
|
|
46
|
+
- Adds chunks to queue
|
|
47
|
+
- Batches rendering via StreamingRenderer
|
|
48
|
+
- Handles thinking blocks directly
|
|
49
|
+
|
|
50
|
+
**Layer 3: Queue Indicator** (fetchAndRenderQueue)
|
|
51
|
+
- Polls via `q.ls` RPC every interval
|
|
52
|
+
- Displays queued items in yellow control blocks
|
|
53
|
+
- Renders with Edit/Delete/Steer buttons
|
|
54
|
+
|
|
55
|
+
**Layer 4: Conversation State** (handleConversationUpdated)
|
|
56
|
+
- Updates isStreaming flag
|
|
57
|
+
- Refreshes UI based on new state
|
|
58
|
+
|
|
59
|
+
## Recent Fixes & Status
|
|
60
|
+
|
|
61
|
+
### Fixed Issues
|
|
62
|
+
|
|
63
|
+
✅ **Queue Message Duplication** (commit fbfd1ad)
|
|
64
|
+
- Problem: Queued messages appeared as both user prompt + queue item
|
|
65
|
+
- Solution: Skip optimistic message when conversation is streaming
|
|
66
|
+
- Impact: Clean queue display, no duplication
|
|
67
|
+
|
|
68
|
+
✅ **Steering Support** (commit 81d83af)
|
|
69
|
+
- Problem: Steering showed "Process not available" error
|
|
70
|
+
- Solution: Keep stdin open (supportsStdin=true, closeStdin=false)
|
|
71
|
+
- Impact: Users can send follow-up prompts during execution
|
|
72
|
+
|
|
73
|
+
✅ **Chunk Rendering Race** (Session 3a)
|
|
74
|
+
- Problem: Early chunks missed before polling started
|
|
75
|
+
- Solution: Immediate chunk fetch on streaming_start
|
|
76
|
+
- Impact: No missing initial output
|
|
77
|
+
|
|
78
|
+
✅ **Dark Mode Selection** (fsbrowse)
|
|
79
|
+
- Problem: Selection states hard to see in dark mode
|
|
80
|
+
- Solution: Theme-aware colors for hover/focus
|
|
81
|
+
- Impact: Better visual feedback in both themes
|
|
82
|
+
|
|
83
|
+
### Remaining Consistency Issues
|
|
84
|
+
|
|
85
|
+
⚠️ **Queue State Sync Timing**
|
|
86
|
+
- Queue created on server via enqueue()
|
|
87
|
+
- message_created broadcast may arrive before queue is populated
|
|
88
|
+
- Client fetchAndRenderQueue() polls but timing is unpredictable
|
|
89
|
+
- No atomic operation ensuring client sees both message and queue
|
|
90
|
+
|
|
91
|
+
⚠️ **Multiple Sources of Truth**
|
|
92
|
+
- Server: database, active execution map, queue map
|
|
93
|
+
- Client: message list, streaming set, queue indicator
|
|
94
|
+
- Conversation state: isStreaming flag can desync if connection drops
|
|
95
|
+
- No single authoritative state object
|
|
96
|
+
|
|
97
|
+
⚠️ **Optimistic Message Updates**
|
|
98
|
+
- Client creates optimistic "sending" message on submit
|
|
99
|
+
- Server creates actual message and broadcasts
|
|
100
|
+
- handleMessageCreated() finds and updates optimistic message
|
|
101
|
+
- If network is slow, both may briefly exist
|
|
102
|
+
|
|
103
|
+
⚠️ **Queue Execution Transition**
|
|
104
|
+
- Queue items don't explicitly transition to executed
|
|
105
|
+
- Client must poll queue indicator to see it disappear
|
|
106
|
+
- No event indicating "queue item now executing"
|
|
107
|
+
- Confusing UX when queue suddenly empties
|
|
108
|
+
|
|
109
|
+
## Data Flow Examples
|
|
110
|
+
|
|
111
|
+
### Normal Execution (Ctrl+Enter with no active stream)
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
User input: "Write a function"
|
|
115
|
+
↓
|
|
116
|
+
startExecution()
|
|
117
|
+
→ _showOptimisticMessage(pendingId, content) // yellow "sending..." message
|
|
118
|
+
↓
|
|
119
|
+
streamToConversation() → msg.stream RPC
|
|
120
|
+
↓
|
|
121
|
+
Server: msg.stream handler
|
|
122
|
+
→ createMessage(p.id, 'user', prompt)
|
|
123
|
+
→ broadcastSync({ type: 'message_created', ... })
|
|
124
|
+
→ startExecution() -> streaming_start broadcast
|
|
125
|
+
↓
|
|
126
|
+
Client: message_created event
|
|
127
|
+
→ handleMessageCreated()
|
|
128
|
+
→ Finds optimistic message by ID
|
|
129
|
+
→ Updates it (removes "sending" style, adds timestamp)
|
|
130
|
+
↓
|
|
131
|
+
Client: streaming_start event
|
|
132
|
+
→ Disables input, shows "thinking..."
|
|
133
|
+
→ Subscribes to session
|
|
134
|
+
→ Starts chunk polling
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Queue Case (Ctrl+Enter while streaming) - NOW FIXED
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
User input: "Now add subtraction" during execution
|
|
141
|
+
↓
|
|
142
|
+
startExecution()
|
|
143
|
+
→ isStreaming = true
|
|
144
|
+
→ SKIP _showOptimisticMessage ✓ (FIXED)
|
|
145
|
+
↓
|
|
146
|
+
streamToConversation() → msg.stream RPC
|
|
147
|
+
↓
|
|
148
|
+
Server: msg.stream handler
|
|
149
|
+
→ createMessage(p.id, 'user', prompt)
|
|
150
|
+
→ broadcastSync({ type: 'message_created', ... })
|
|
151
|
+
→ activeExecutions.has(p.id) = true
|
|
152
|
+
→ enqueue(p.id, prompt, ...)
|
|
153
|
+
→ broadcastSync({ type: 'queue_status', ... })
|
|
154
|
+
↓
|
|
155
|
+
Client: message_created event
|
|
156
|
+
→ handleMessageCreated()
|
|
157
|
+
→ Message is not in current conversation display
|
|
158
|
+
→ Emit event but don't render
|
|
159
|
+
↓
|
|
160
|
+
Client: queue_status event
|
|
161
|
+
→ handleQueueStatus()
|
|
162
|
+
→ fetchAndRenderQueue()
|
|
163
|
+
→ Displays in yellow queue indicator ✓
|
|
164
|
+
↓
|
|
165
|
+
Result: Message only in queue, no duplication ✓
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Steering (Ctrl+Enter + Steer button during execution)
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
User clicks Steer button on queued message
|
|
172
|
+
↓
|
|
173
|
+
Client: conv.steer RPC with JSON-RPC format
|
|
174
|
+
↓
|
|
175
|
+
Server: conv.steer handler
|
|
176
|
+
→ Finds active process stdin
|
|
177
|
+
→ Writes JSON-RPC prompt request
|
|
178
|
+
→ Removes from queue (optional)
|
|
179
|
+
↓
|
|
180
|
+
Claude Code: Receives JSON-RPC on stdin
|
|
181
|
+
→ Processes prompt immediately
|
|
182
|
+
→ Continues execution with new direction
|
|
183
|
+
↓
|
|
184
|
+
Client: receives streaming_progress chunks
|
|
185
|
+
→ Renders new content below existing output
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Consistency Guarantees
|
|
189
|
+
|
|
190
|
+
### What IS Consistent
|
|
191
|
+
- Messages don't duplicate (handled in handleMessageCreated)
|
|
192
|
+
- Streaming chunks are ordered (via session ID subscription)
|
|
193
|
+
- Queue items maintain order (FIFO in server queue map)
|
|
194
|
+
- Tool installations tracked atomically (per-tool in database)
|
|
195
|
+
|
|
196
|
+
### What IS NOT Guaranteed
|
|
197
|
+
- Client queue display and server queue state may briefly desync
|
|
198
|
+
- If connection drops mid-queue, client state becomes stale
|
|
199
|
+
- No explicit confirmation that user message was queued
|
|
200
|
+
- Queue item execution not signaled with explicit event
|
|
201
|
+
|
|
202
|
+
## Recommendations for Further Improvement
|
|
203
|
+
|
|
204
|
+
### Priority 1: Explicit Queue Lifecycle
|
|
205
|
+
Add dedicated broadcast events:
|
|
206
|
+
```javascript
|
|
207
|
+
'message_queued' // Sent when msg added to queue
|
|
208
|
+
'queue_item_executing' // Sent when queue item becomes active
|
|
209
|
+
'queue_item_completed' // Sent when queue item finished
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Priority 2: Atomic State Snapshots
|
|
213
|
+
Periodically broadcast conversation state:
|
|
214
|
+
```javascript
|
|
215
|
+
{
|
|
216
|
+
type: 'conversation_state',
|
|
217
|
+
conversationId,
|
|
218
|
+
state: {
|
|
219
|
+
isStreaming: boolean,
|
|
220
|
+
messageCount: number,
|
|
221
|
+
queueLength: number,
|
|
222
|
+
lastMessageId: string,
|
|
223
|
+
lastUpdate: timestamp
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Priority 3: Deterministic Client State Machine
|
|
229
|
+
Each conversation has explicit state mode:
|
|
230
|
+
```javascript
|
|
231
|
+
state = {
|
|
232
|
+
mode: 'IDLE' | 'STREAMING' | 'QUEUED',
|
|
233
|
+
messages: [],
|
|
234
|
+
queue: [],
|
|
235
|
+
isTransitioning: boolean // true when queue→execute
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Priority 4: Connection Recovery
|
|
240
|
+
After reconnect, fetch complete conversation state:
|
|
241
|
+
```javascript
|
|
242
|
+
await wsClient.rpc('conv.sync', { id: conversationId })
|
|
243
|
+
// Returns: { messages, queue, isStreaming, chunks }
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Testing Matrix
|
|
247
|
+
|
|
248
|
+
| Scenario | Before Fix | After Fix | Status |
|
|
249
|
+
|----------|-----------|-----------|--------|
|
|
250
|
+
| Ctrl+Enter (not streaming) | ✓ Works | ✓ Works | ✓ OK |
|
|
251
|
+
| Ctrl+Enter (streaming) | ✗ Duplicate | ✓ Single queue item | ✓ FIXED |
|
|
252
|
+
| Queue indicator updates | ⚠️ Polling | ⚠️ Polling | ⚠️ Could improve |
|
|
253
|
+
| Steering works | ✗ "Not available" | ✓ Works | ✓ FIXED |
|
|
254
|
+
| Multiple queued items | ⚠️ Order unclear | ⚠️ Order unclear | ⚠️ OK but could confirm |
|
|
255
|
+
| Page reload with queue | ✗ Queue lost | ✓ Persisted | ✓ OK |
|
|
256
|
+
| Network disconnect | ⚠️ State stale | ⚠️ State stale | ⚠️ Could improve |
|
|
257
|
+
|
|
258
|
+
## Files Modified in Latest Session
|
|
259
|
+
|
|
260
|
+
1. **lib/claude-runner.js** - Steering support
|
|
261
|
+
- Changed `supportsStdin: false` → `true`
|
|
262
|
+
- Changed `closeStdin: true` → `false`
|
|
263
|
+
- Removed positional prompt argument
|
|
264
|
+
|
|
265
|
+
2. **static/js/client.js** - Queue display fix
|
|
266
|
+
- Skip optimistic message when streaming
|
|
267
|
+
- Skip confirm/fail handlers for queued messages
|
|
268
|
+
|
|
269
|
+
3. **static/index.html** - Hamburger animation
|
|
270
|
+
- Added `transition: none` to .sidebar
|
|
271
|
+
|
|
272
|
+
4. **fsbrowse/public/style.css** - Dark mode colors
|
|
273
|
+
- Improved hover states
|
|
274
|
+
- Theme-aware modal focus colors
|
|
275
|
+
|
|
276
|
+
## Conclusion
|
|
277
|
+
|
|
278
|
+
The sync-to-display system is now more consistent with the queue display fix and steering support. The architecture handles the primary use cases well, but could benefit from explicit lifecycle events and atomic state snapshots for guaranteed consistency. The current implementation is pragmatic and performant, trading off some guarantees for simplicity and responsiveness.
|
|
279
|
+
|
package/lib/ws-handlers-conv.js
CHANGED
|
@@ -205,13 +205,17 @@ export function register(router, deps) {
|
|
|
205
205
|
const idempotencyKey = p.idempotencyKey || null;
|
|
206
206
|
const message = queries.createMessage(p.id, 'user', p.content, idempotencyKey);
|
|
207
207
|
queries.createEvent('message.created', { role: 'user', messageId: message.id }, p.id);
|
|
208
|
-
|
|
209
|
-
if
|
|
210
|
-
|
|
211
|
-
|
|
208
|
+
|
|
209
|
+
// Only broadcast message_created if NOT queuing - queued messages show in queue indicator instead
|
|
210
|
+
if (!activeExecutions.has(p.id)) {
|
|
211
|
+
broadcastSync({ type: 'message_created', conversationId: p.id, message, timestamp: Date.now() });
|
|
212
|
+
const session = startExecution(p.id, message, agentId, model, p.content, subAgent);
|
|
213
|
+
return { message, session, idempotencyKey };
|
|
212
214
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
+
|
|
216
|
+
// Message is queued - don't broadcast as message_created, let queue_status handle the UI update
|
|
217
|
+
const qp = enqueue(p.id, p.content, agentId, model, message.id, subAgent);
|
|
218
|
+
return { message, queued: true, queuePosition: qp, idempotencyKey };
|
|
215
219
|
});
|
|
216
220
|
|
|
217
221
|
router.handle('msg.get', (p) => {
|
|
@@ -229,13 +233,17 @@ export function register(router, deps) {
|
|
|
229
233
|
const subAgent = p.subAgent || conv.subAgent || null;
|
|
230
234
|
const userMessage = queries.createMessage(p.id, 'user', prompt);
|
|
231
235
|
queries.createEvent('message.created', { role: 'user', messageId: userMessage.id }, p.id);
|
|
232
|
-
|
|
233
|
-
if
|
|
234
|
-
|
|
235
|
-
|
|
236
|
+
|
|
237
|
+
// Only broadcast message_created if NOT queuing - queued messages show in queue indicator instead
|
|
238
|
+
if (!activeExecutions.has(p.id)) {
|
|
239
|
+
broadcastSync({ type: 'message_created', conversationId: p.id, message: userMessage, timestamp: Date.now() });
|
|
240
|
+
const session = startExecution(p.id, userMessage, agentId, model, prompt, subAgent);
|
|
241
|
+
return { message: userMessage, session, streamId: session.id };
|
|
236
242
|
}
|
|
237
|
-
|
|
238
|
-
|
|
243
|
+
|
|
244
|
+
// Message is queued - don't broadcast as message_created, let queue_status handle the UI update
|
|
245
|
+
const qp = enqueue(p.id, prompt, agentId, model, userMessage.id, subAgent);
|
|
246
|
+
return { message: userMessage, queued: true, queuePosition: qp };
|
|
239
247
|
});
|
|
240
248
|
|
|
241
249
|
router.handle('q.ls', (p) => {
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -4179,7 +4179,16 @@ function drainMessageQueue(conversationId) {
|
|
|
4179
4179
|
const next = queue.shift();
|
|
4180
4180
|
if (queue.length === 0) messageQueues.delete(conversationId);
|
|
4181
4181
|
|
|
4182
|
-
debugLog(`[queue] Draining next message for ${conversationId}`);
|
|
4182
|
+
debugLog(`[queue] Draining next message for ${conversationId}, messageId=${next.messageId}`);
|
|
4183
|
+
|
|
4184
|
+
// Broadcast queue_item_dequeued so client can update UI immediately
|
|
4185
|
+
broadcastSync({
|
|
4186
|
+
type: 'queue_item_dequeued',
|
|
4187
|
+
conversationId,
|
|
4188
|
+
messageId: next.messageId,
|
|
4189
|
+
queueLength: queue?.length || 0,
|
|
4190
|
+
timestamp: Date.now()
|
|
4191
|
+
});
|
|
4183
4192
|
|
|
4184
4193
|
const session = queries.createSession(conversationId);
|
|
4185
4194
|
queries.createEvent('session.created', { messageId: next.messageId, sessionId: session.id }, conversationId, session.id);
|
package/static/js/client.js
CHANGED
|
@@ -691,18 +691,23 @@ class AgentGUIClient {
|
|
|
691
691
|
|
|
692
692
|
switch (data.type) {
|
|
693
693
|
case 'streaming_start':
|
|
694
|
+
window.syncDebugger?.logEvent('streaming_start', data);
|
|
694
695
|
this.handleStreamingStart(data).catch(e => console.error('handleStreamingStart error:', e));
|
|
695
696
|
break;
|
|
696
697
|
case 'streaming_resumed':
|
|
698
|
+
window.syncDebugger?.logEvent('streaming_resumed', data);
|
|
697
699
|
this.handleStreamingResumed(data).catch(e => console.error('handleStreamingResumed error:', e));
|
|
698
700
|
break;
|
|
699
701
|
case 'streaming_progress':
|
|
702
|
+
window.syncDebugger?.logEvent('streaming_progress', { sessionId: data.sessionId });
|
|
700
703
|
this.handleStreamingProgress(data);
|
|
701
704
|
break;
|
|
702
705
|
case 'streaming_complete':
|
|
706
|
+
window.syncDebugger?.logEvent('streaming_complete', data);
|
|
703
707
|
this.handleStreamingComplete(data);
|
|
704
708
|
break;
|
|
705
709
|
case 'streaming_error':
|
|
710
|
+
window.syncDebugger?.logEvent('streaming_error', data);
|
|
706
711
|
this.handleStreamingError(data);
|
|
707
712
|
break;
|
|
708
713
|
case 'conversation_created':
|
|
@@ -712,14 +717,22 @@ class AgentGUIClient {
|
|
|
712
717
|
this.handleAllConversationsDeleted(data);
|
|
713
718
|
break;
|
|
714
719
|
case 'message_created':
|
|
720
|
+
window.syncDebugger?.logEvent('message_created', data);
|
|
715
721
|
this.handleMessageCreated(data);
|
|
716
722
|
break;
|
|
723
|
+
case 'conversation_updated':
|
|
724
|
+
window.syncDebugger?.logEvent('conversation_updated', data);
|
|
725
|
+
this.handleConversationUpdated(data);
|
|
726
|
+
break;
|
|
717
727
|
case 'queue_status':
|
|
718
728
|
this.handleQueueStatus(data);
|
|
719
729
|
break;
|
|
720
730
|
case 'queue_updated':
|
|
721
731
|
this.handleQueueUpdated(data);
|
|
722
732
|
break;
|
|
733
|
+
case 'queue_item_dequeued':
|
|
734
|
+
this.handleQueueItemDequeued(data);
|
|
735
|
+
break;
|
|
723
736
|
case 'rate_limit_hit':
|
|
724
737
|
this.handleRateLimitHit(data);
|
|
725
738
|
break;
|
|
@@ -778,6 +791,7 @@ class AgentGUIClient {
|
|
|
778
791
|
if (this.state.currentConversation?.id !== data.conversationId) {
|
|
779
792
|
console.log('Streaming started for non-active conversation:', data.conversationId);
|
|
780
793
|
this.state.streamingConversations.set(data.conversationId, true);
|
|
794
|
+
console.log('[SYNC] streaming_start - non-active conv:', { convId: data.conversationId, sessionId: data.sessionId, streamingCount: this.state.streamingConversations.size });
|
|
781
795
|
this.updateBusyPromptArea(data.conversationId);
|
|
782
796
|
this.emit('streaming:start', data);
|
|
783
797
|
|
|
@@ -1140,21 +1154,31 @@ class AgentGUIClient {
|
|
|
1140
1154
|
if (conversationId && this.state.currentConversation?.id !== conversationId) {
|
|
1141
1155
|
console.log('Streaming completed for non-active conversation:', conversationId);
|
|
1142
1156
|
this.state.streamingConversations.delete(conversationId);
|
|
1157
|
+
console.log('[SYNC] streaming_complete - non-active conv:', { convId: conversationId, streamingCount: this.state.streamingConversations.size });
|
|
1143
1158
|
this.updateBusyPromptArea(conversationId);
|
|
1144
1159
|
this.emit('streaming:complete', data);
|
|
1145
1160
|
return;
|
|
1146
1161
|
}
|
|
1147
1162
|
|
|
1148
1163
|
this.state.streamingConversations.delete(conversationId);
|
|
1164
|
+
console.log('[SYNC] streaming_complete - active conv:', { convId: conversationId, streamingCount: this.state.streamingConversations.size, interrupted: data.interrupted });
|
|
1149
1165
|
this.updateBusyPromptArea(conversationId);
|
|
1150
1166
|
|
|
1167
|
+
const sessionId = data.sessionId || this.state.currentSession?.id;
|
|
1151
1168
|
|
|
1169
|
+
// Unsubscribe from session to prevent subscription leak
|
|
1170
|
+
if (sessionId && this.wsManager) {
|
|
1171
|
+
try {
|
|
1172
|
+
this.wsManager.unsubscribeFromSession(sessionId);
|
|
1173
|
+
} catch (e) {
|
|
1174
|
+
// Session may not exist, ignore
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1152
1177
|
|
|
1153
1178
|
// Clear queue indicator when streaming completes
|
|
1154
1179
|
const queueEl = document.querySelector('.queue-indicator');
|
|
1155
1180
|
if (queueEl) queueEl.remove();
|
|
1156
1181
|
|
|
1157
|
-
const sessionId = data.sessionId || this.state.currentSession?.id;
|
|
1158
1182
|
// Remove ALL streaming indicators from the entire messages container
|
|
1159
1183
|
const outputEl2 = document.getElementById('output');
|
|
1160
1184
|
if (outputEl2) {
|
|
@@ -1220,6 +1244,13 @@ class AgentGUIClient {
|
|
|
1220
1244
|
return;
|
|
1221
1245
|
}
|
|
1222
1246
|
|
|
1247
|
+
console.log('[SYNC] message_created:', { msgId: data.message.id, role: data.message.role, convId: data.conversationId });
|
|
1248
|
+
|
|
1249
|
+
// Update messageCount in current conversation state for user messages
|
|
1250
|
+
if (data.message.role === 'user' && this.state.currentConversation) {
|
|
1251
|
+
this.state.currentConversation.messageCount = (this.state.currentConversation.messageCount || 0) + 1;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1223
1254
|
if (data.message.role === 'assistant' && this.state.streamingConversations.has(data.conversationId)) {
|
|
1224
1255
|
this.emit('message:created', data);
|
|
1225
1256
|
return;
|
|
@@ -1279,6 +1310,15 @@ class AgentGUIClient {
|
|
|
1279
1310
|
this.emit('message:created', data);
|
|
1280
1311
|
}
|
|
1281
1312
|
|
|
1313
|
+
handleConversationUpdated(data) {
|
|
1314
|
+
// Update current conversation metadata if this is the active conversation
|
|
1315
|
+
if (data.conversation && data.conversation.id === this.state.currentConversation?.id) {
|
|
1316
|
+
this.state.currentConversation = data.conversation;
|
|
1317
|
+
}
|
|
1318
|
+
// Emit event for sidebar/other listeners
|
|
1319
|
+
this.emit('conversation:updated', data);
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1282
1322
|
handleQueueStatus(data) {
|
|
1283
1323
|
if (data.conversationId !== this.state.currentConversation?.id) return;
|
|
1284
1324
|
this.fetchAndRenderQueue(data.conversationId);
|
|
@@ -1289,6 +1329,13 @@ class AgentGUIClient {
|
|
|
1289
1329
|
this.fetchAndRenderQueue(data.conversationId);
|
|
1290
1330
|
}
|
|
1291
1331
|
|
|
1332
|
+
handleQueueItemDequeued(data) {
|
|
1333
|
+
if (data.conversationId !== this.state.currentConversation?.id) return;
|
|
1334
|
+
// Item was dequeued and execution started - remove from queue indicator
|
|
1335
|
+
// and update queue display
|
|
1336
|
+
this.fetchAndRenderQueue(data.conversationId);
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1292
1339
|
async fetchAndRenderQueue(conversationId) {
|
|
1293
1340
|
const outputEl = document.querySelector('.conversation-messages');
|
|
1294
1341
|
if (!outputEl) return;
|
|
@@ -1848,7 +1895,26 @@ class AgentGUIClient {
|
|
|
1848
1895
|
latencyEma: self.wsManager?._latencyEma || null,
|
|
1849
1896
|
serverProcessingEstimate: self._serverProcessingEstimate,
|
|
1850
1897
|
latencyTrend: self.wsManager?.latency?.trend || null
|
|
1851
|
-
})
|
|
1898
|
+
}),
|
|
1899
|
+
|
|
1900
|
+
// Sync-to-display debugging
|
|
1901
|
+
getSyncState: () => ({
|
|
1902
|
+
currentConversation: self.state.currentConversation,
|
|
1903
|
+
isStreaming: self.state.currentConversation && self.state.streamingConversations.has(self.state.currentConversation.id),
|
|
1904
|
+
streamingConversations: Array.from(self.state.streamingConversations),
|
|
1905
|
+
rendererEventQueueLength: self.renderer?.eventQueue?.length || 0,
|
|
1906
|
+
rendererEventHistoryLength: self.renderer?.eventHistory?.length || 0,
|
|
1907
|
+
}),
|
|
1908
|
+
|
|
1909
|
+
// Message DOM state
|
|
1910
|
+
getMessageState: () => {
|
|
1911
|
+
const output = document.querySelector('.conversation-messages');
|
|
1912
|
+
if (!output) return { error: 'No conversation output found' };
|
|
1913
|
+
const messageCount = output.querySelectorAll('.message').length;
|
|
1914
|
+
const queueItems = output.querySelectorAll('.queue-item').length;
|
|
1915
|
+
const pendingMessages = output.querySelectorAll('.message-sending').length;
|
|
1916
|
+
return { messageCount, queueItems, pendingMessages };
|
|
1917
|
+
}
|
|
1852
1918
|
};
|
|
1853
1919
|
}
|
|
1854
1920
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync-to-Display Debug System
|
|
3
|
+
* Comprehensive logging and state validation for sync/render pipeline
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
class SyncDebugger {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.enabled = false;
|
|
9
|
+
this.eventLog = [];
|
|
10
|
+
this.maxLogSize = 500;
|
|
11
|
+
this.stateSnapshots = [];
|
|
12
|
+
this.processingStack = [];
|
|
13
|
+
this.startTime = Date.now();
|
|
14
|
+
|
|
15
|
+
// Event tracking
|
|
16
|
+
this.processedEventIds = new Set();
|
|
17
|
+
this.eventCounts = {};
|
|
18
|
+
|
|
19
|
+
// Timing
|
|
20
|
+
this.timingMarkers = {};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
enable() {
|
|
24
|
+
this.enabled = true;
|
|
25
|
+
console.log('[SyncDebug] Enabled');
|
|
26
|
+
window.SyncDebugger = this;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
disable() {
|
|
30
|
+
this.enabled = false;
|
|
31
|
+
console.log('[SyncDebug] Disabled');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
logEvent(type, data) {
|
|
35
|
+
if (!this.enabled) return;
|
|
36
|
+
|
|
37
|
+
const timestamp = Date.now() - this.startTime;
|
|
38
|
+
const eventId = data?.id || data?.messageId || data?.conversationId || '?';
|
|
39
|
+
|
|
40
|
+
// Check for duplicates
|
|
41
|
+
const isDuplicate = this.processedEventIds.has(eventId) &&
|
|
42
|
+
this.eventLog.some(e => e.type === type && e.eventId === eventId);
|
|
43
|
+
|
|
44
|
+
const entry = {
|
|
45
|
+
timestamp,
|
|
46
|
+
type,
|
|
47
|
+
eventId,
|
|
48
|
+
isDuplicate,
|
|
49
|
+
dataKeys: data ? Object.keys(data) : [],
|
|
50
|
+
dataSize: JSON.stringify(data || {}).length
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
this.eventLog.push(entry);
|
|
54
|
+
this.eventCounts[type] = (this.eventCounts[type] || 0) + 1;
|
|
55
|
+
this.processedEventIds.add(eventId);
|
|
56
|
+
|
|
57
|
+
if (this.eventLog.length > this.maxLogSize) {
|
|
58
|
+
this.eventLog.shift();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (isDuplicate) {
|
|
62
|
+
console.warn(`[SyncDebug] DUPLICATE EVENT: ${type} - ${eventId}`, entry);
|
|
63
|
+
} else {
|
|
64
|
+
console.log(`[SyncDebug] Event: ${type} (${timestamp}ms)`, entry);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
logStateChange(name, before, after) {
|
|
69
|
+
if (!this.enabled) return;
|
|
70
|
+
|
|
71
|
+
const timestamp = Date.now() - this.startTime;
|
|
72
|
+
const snapshot = {
|
|
73
|
+
timestamp,
|
|
74
|
+
name,
|
|
75
|
+
before: JSON.parse(JSON.stringify(before)),
|
|
76
|
+
after: JSON.parse(JSON.stringify(after)),
|
|
77
|
+
changed: JSON.stringify(before) !== JSON.stringify(after)
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
this.stateSnapshots.push(snapshot);
|
|
81
|
+
if (this.stateSnapshots.length > this.maxLogSize) {
|
|
82
|
+
this.stateSnapshots.shift();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (snapshot.changed) {
|
|
86
|
+
console.log(`[SyncDebug] State changed: ${name}`, snapshot);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
pushOperation(name) {
|
|
91
|
+
const marker = { name, start: Date.now() };
|
|
92
|
+
this.processingStack.push(marker);
|
|
93
|
+
console.log(`[SyncDebug] > ${name}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
popOperation() {
|
|
97
|
+
const marker = this.processingStack.pop();
|
|
98
|
+
if (marker) {
|
|
99
|
+
const duration = Date.now() - marker.start;
|
|
100
|
+
console.log(`[SyncDebug] < ${marker.name} (${duration}ms)`);
|
|
101
|
+
if (duration > 100) {
|
|
102
|
+
console.warn(`[SyncDebug] SLOW: ${marker.name} took ${duration}ms`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
getReport() {
|
|
108
|
+
return {
|
|
109
|
+
totalEvents: this.eventLog.length,
|
|
110
|
+
eventCounts: this.eventCounts,
|
|
111
|
+
duplicates: this.eventLog.filter(e => e.isDuplicate).length,
|
|
112
|
+
stateChanges: this.stateSnapshots.length,
|
|
113
|
+
uniqueEventIds: this.processedEventIds.size,
|
|
114
|
+
recentEvents: this.eventLog.slice(-20),
|
|
115
|
+
recentStateChanges: this.stateSnapshots.slice(-20)
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
printReport() {
|
|
120
|
+
const report = this.getReport();
|
|
121
|
+
console.table(report);
|
|
122
|
+
console.table(report.recentEvents);
|
|
123
|
+
console.table(report.recentStateChanges);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
clearLogs() {
|
|
127
|
+
this.eventLog = [];
|
|
128
|
+
this.stateSnapshots = [];
|
|
129
|
+
this.processedEventIds.clear();
|
|
130
|
+
this.eventCounts = {};
|
|
131
|
+
this.processingStack = [];
|
|
132
|
+
console.log('[SyncDebug] Logs cleared');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Create global instance
|
|
137
|
+
window.syncDebugger = new SyncDebugger();
|
|
138
|
+
|
|
139
|
+
// Expose commands
|
|
140
|
+
window.debugSync = {
|
|
141
|
+
enable: () => window.syncDebugger.enable(),
|
|
142
|
+
disable: () => window.syncDebugger.disable(),
|
|
143
|
+
report: () => window.syncDebugger.printReport(),
|
|
144
|
+
clear: () => window.syncDebugger.clearLogs(),
|
|
145
|
+
get: () => window.syncDebugger.getReport()
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
console.log('[SyncDebug] Available. Use: debugSync.enable(), debugSync.report(), debugSync.clear()');
|