agentgui 1.0.65 → 1.0.66
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/.prd +54 -0
- package/CLAUDE.md +176 -0
- package/lib/claude-runner.js +71 -0
- package/lib/database-service.ts +388 -0
- package/lib/machines.ts +593 -0
- package/lib/schemas.ts +213 -0
- package/lib/sync-service.ts +340 -0
- package/lib/types.ts +245 -0
- package/package.json +1 -1
- package/server.js +69 -277
- package/static/app.js +210 -1575
- package/static/styles.css +135 -0
- package/conversation-sync.js +0 -196
- package/state-manager.js +0 -360
- package/state-validator.js +0 -150
- package/static/sync-manager.js +0 -273
- package/stream-handler.js +0 -106
package/state-validator.js
DELETED
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
import { queries } from './database.js';
|
|
2
|
-
import crypto from 'crypto';
|
|
3
|
-
|
|
4
|
-
export class StateValidator {
|
|
5
|
-
/**
|
|
6
|
-
* Validates data consistency by checking:
|
|
7
|
-
* 1. Sequence numbers are consecutive (no gaps)
|
|
8
|
-
* 2. Stream updates match database
|
|
9
|
-
* 3. Final message matches aggregated stream updates
|
|
10
|
-
*/
|
|
11
|
-
static validateSession(sessionId) {
|
|
12
|
-
try {
|
|
13
|
-
const session = queries.getSession(sessionId);
|
|
14
|
-
if (!session) return { valid: false, error: 'Session not found' };
|
|
15
|
-
|
|
16
|
-
const updates = queries.getSessionStreamUpdates(sessionId);
|
|
17
|
-
|
|
18
|
-
// Check 1: Sequence continuity
|
|
19
|
-
const sequenceGaps = [];
|
|
20
|
-
for (let i = 0; i < updates.length - 1; i++) {
|
|
21
|
-
if (updates[i + 1].sequence !== updates[i].sequence + 1) {
|
|
22
|
-
sequenceGaps.push({ expected: updates[i].sequence + 1, actual: updates[i + 1].sequence });
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
if (sequenceGaps.length > 0) {
|
|
27
|
-
return {
|
|
28
|
-
valid: false,
|
|
29
|
-
error: 'Sequence gaps detected',
|
|
30
|
-
gaps: sequenceGaps
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Check 2: Stream update count matches
|
|
35
|
-
const textUpdates = updates.filter(u => u.updateType === 'text');
|
|
36
|
-
const htmlUpdates = updates.filter(u => u.updateType === 'html');
|
|
37
|
-
const imageUpdates = updates.filter(u => u.updateType === 'image');
|
|
38
|
-
|
|
39
|
-
// Check 3: Sequence starts at 0
|
|
40
|
-
if (updates.length > 0 && updates[0].sequence !== 0) {
|
|
41
|
-
return {
|
|
42
|
-
valid: false,
|
|
43
|
-
error: 'Sequence should start at 0',
|
|
44
|
-
firstSequence: updates[0].sequence
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
return {
|
|
49
|
-
valid: true,
|
|
50
|
-
sessionId,
|
|
51
|
-
updateCount: updates.length,
|
|
52
|
-
textCount: textUpdates.length,
|
|
53
|
-
htmlCount: htmlUpdates.length,
|
|
54
|
-
imageCount: imageUpdates.length,
|
|
55
|
-
latestSequence: updates.length > 0 ? updates[updates.length - 1].sequence : -1,
|
|
56
|
-
checkpoint: this.createChecksum(updates)
|
|
57
|
-
};
|
|
58
|
-
} catch (err) {
|
|
59
|
-
return {
|
|
60
|
-
valid: false,
|
|
61
|
-
error: err.message
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Creates checksum of stream updates for integrity verification
|
|
68
|
-
*/
|
|
69
|
-
static createChecksum(updates) {
|
|
70
|
-
const data = updates
|
|
71
|
-
.map(u => `${u.sequence}:${u.updateType}:${u.created_at}`)
|
|
72
|
-
.join('|');
|
|
73
|
-
return crypto.createHash('sha256').update(data).digest('hex');
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Verifies checksum hasn't changed (data integrity)
|
|
78
|
-
*/
|
|
79
|
-
static verifyChecksum(updates, expectedChecksum) {
|
|
80
|
-
return this.createChecksum(updates) === expectedChecksum;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Gets current state for client recovery
|
|
85
|
-
*/
|
|
86
|
-
static getSessionState(sessionId) {
|
|
87
|
-
const session = queries.getSession(sessionId);
|
|
88
|
-
if (!session) return null;
|
|
89
|
-
|
|
90
|
-
const updates = queries.getSessionStreamUpdates(sessionId);
|
|
91
|
-
const validation = this.validateSession(sessionId);
|
|
92
|
-
|
|
93
|
-
return {
|
|
94
|
-
session: {
|
|
95
|
-
id: session.id,
|
|
96
|
-
conversationId: session.conversationId,
|
|
97
|
-
status: session.status,
|
|
98
|
-
started_at: session.started_at,
|
|
99
|
-
completed_at: session.completed_at
|
|
100
|
-
},
|
|
101
|
-
updates: updates.map(u => ({
|
|
102
|
-
sequence: u.sequence,
|
|
103
|
-
updateType: u.updateType,
|
|
104
|
-
content: u.content,
|
|
105
|
-
created_at: u.created_at
|
|
106
|
-
})),
|
|
107
|
-
validation,
|
|
108
|
-
checkpoint: validation.checkpoint,
|
|
109
|
-
recoveryPoint: {
|
|
110
|
-
lastSequence: updates.length > 0 ? updates[updates.length - 1].sequence : -1,
|
|
111
|
-
totalUpdates: updates.length,
|
|
112
|
-
timestamp: Date.now()
|
|
113
|
-
}
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Validates incoming update against current state
|
|
119
|
-
*/
|
|
120
|
-
static validateUpdate(sessionId, incomingUpdate, lastKnownSequence) {
|
|
121
|
-
const updates = queries.getSessionStreamUpdates(sessionId);
|
|
122
|
-
const maxSequence = updates.length > 0 ? updates[updates.length - 1].sequence : -1;
|
|
123
|
-
|
|
124
|
-
// Check if this is the next expected sequence
|
|
125
|
-
const expectedSequence = maxSequence + 1;
|
|
126
|
-
if (incomingUpdate.sequence !== expectedSequence) {
|
|
127
|
-
return {
|
|
128
|
-
valid: false,
|
|
129
|
-
error: 'Sequence out of order',
|
|
130
|
-
expected: expectedSequence,
|
|
131
|
-
received: incomingUpdate.sequence,
|
|
132
|
-
action: 'FETCH_MISSING_UPDATES'
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Check for duplicates (same sequence already exists)
|
|
137
|
-
if (updates.some(u => u.sequence === incomingUpdate.sequence)) {
|
|
138
|
-
return {
|
|
139
|
-
valid: false,
|
|
140
|
-
error: 'Duplicate update detected',
|
|
141
|
-
sequence: incomingUpdate.sequence,
|
|
142
|
-
action: 'IGNORE_DUPLICATE'
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
return { valid: true, sequence: expectedSequence };
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export default StateValidator;
|
package/static/sync-manager.js
DELETED
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sync Manager - Handles real-time synchronization with automatic reconnection
|
|
3
|
-
* Guarantees: No lost data, perfect recovery, consistent state
|
|
4
|
-
*/
|
|
5
|
-
class SyncManager {
|
|
6
|
-
constructor() {
|
|
7
|
-
this.ws = null;
|
|
8
|
-
this.clientId = null;
|
|
9
|
-
this.subscriptions = new Map();
|
|
10
|
-
this.reconnectAttempts = 0;
|
|
11
|
-
this.maxReconnectAttempts = 10;
|
|
12
|
-
this.reconnectDelay = 1000;
|
|
13
|
-
this.isConnected = false;
|
|
14
|
-
this.handlers = new Map();
|
|
15
|
-
this.lastCheckpoint = new Map();
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Connect to sync server with automatic reconnection
|
|
20
|
-
*/
|
|
21
|
-
connect() {
|
|
22
|
-
return new Promise((resolve, reject) => {
|
|
23
|
-
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
24
|
-
const url = `${protocol}//${window.location.host}${window.__BASE_URL || '/gm'}/sync`;
|
|
25
|
-
|
|
26
|
-
try {
|
|
27
|
-
this.ws = new WebSocket(url);
|
|
28
|
-
|
|
29
|
-
this.ws.onopen = () => {
|
|
30
|
-
console.log('[SyncManager] Connected to server');
|
|
31
|
-
this.isConnected = true;
|
|
32
|
-
this.reconnectAttempts = 0;
|
|
33
|
-
this.emit('connected', { clientId: this.clientId });
|
|
34
|
-
|
|
35
|
-
// Resubscribe to all previously subscribed sessions
|
|
36
|
-
for (const [sessionId, handlers] of this.subscriptions) {
|
|
37
|
-
this.subscribe(sessionId, handlers.onUpdate, handlers.onRecover);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
resolve();
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
this.ws.onmessage = (event) => {
|
|
44
|
-
this.handleMessage(JSON.parse(event.data));
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
this.ws.onclose = () => {
|
|
48
|
-
console.log('[SyncManager] Disconnected from server');
|
|
49
|
-
this.isConnected = false;
|
|
50
|
-
this.attemptReconnect();
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
this.ws.onerror = (error) => {
|
|
54
|
-
console.error('[SyncManager] WebSocket error:', error);
|
|
55
|
-
reject(error);
|
|
56
|
-
};
|
|
57
|
-
} catch (err) {
|
|
58
|
-
console.error('[SyncManager] Failed to create WebSocket:', err);
|
|
59
|
-
reject(err);
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Handle incoming messages
|
|
66
|
-
*/
|
|
67
|
-
handleMessage(message) {
|
|
68
|
-
const { type, sessionId, clientId } = message;
|
|
69
|
-
|
|
70
|
-
if (type === 'sync_connected') {
|
|
71
|
-
this.clientId = message.clientId;
|
|
72
|
-
console.log(`[SyncManager] Assigned client ID: ${this.clientId}`);
|
|
73
|
-
} else if (type === 'state_snapshot') {
|
|
74
|
-
// Received state after subscription
|
|
75
|
-
console.log(`[SyncManager] Received state snapshot for ${sessionId}`);
|
|
76
|
-
this.lastCheckpoint.set(sessionId, message.state.checkpoint);
|
|
77
|
-
|
|
78
|
-
const handlers = this.subscriptions.get(sessionId);
|
|
79
|
-
if (handlers?.onRecover) {
|
|
80
|
-
handlers.onRecover(message.state);
|
|
81
|
-
}
|
|
82
|
-
} else if (type === 'recovery_response') {
|
|
83
|
-
// Received full state recovery
|
|
84
|
-
console.log(`[SyncManager] Received recovery response for ${sessionId}`);
|
|
85
|
-
this.lastCheckpoint.set(sessionId, message.state.checkpoint);
|
|
86
|
-
|
|
87
|
-
const handlers = this.subscriptions.get(sessionId);
|
|
88
|
-
if (handlers?.onRecover) {
|
|
89
|
-
handlers.onRecover(message.state);
|
|
90
|
-
}
|
|
91
|
-
} else if (type === 'stream_update') {
|
|
92
|
-
// Real-time update from server
|
|
93
|
-
this.lastCheckpoint.set(sessionId, message.timestamp);
|
|
94
|
-
|
|
95
|
-
const handlers = this.subscriptions.get(sessionId);
|
|
96
|
-
if (handlers?.onUpdate) {
|
|
97
|
-
try {
|
|
98
|
-
handlers.onUpdate(message);
|
|
99
|
-
} catch (err) {
|
|
100
|
-
console.error(`[SyncManager] Error in update handler: ${err.message}`);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Subscribe to session updates with callbacks
|
|
108
|
-
* @param {string} sessionId
|
|
109
|
-
* @param {Function} onUpdate - Called for each real-time update
|
|
110
|
-
* @param {Function} onRecover - Called with full state on subscribe/reconnect
|
|
111
|
-
*/
|
|
112
|
-
subscribe(sessionId, onUpdate, onRecover) {
|
|
113
|
-
if (!this.subscriptions.has(sessionId)) {
|
|
114
|
-
this.subscriptions.set(sessionId, { onUpdate, onRecover });
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (this.isConnected && this.ws?.readyState === WebSocket.OPEN) {
|
|
118
|
-
this.ws.send(JSON.stringify({
|
|
119
|
-
type: 'subscribe',
|
|
120
|
-
sessionId
|
|
121
|
-
}));
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Unsubscribe from session
|
|
127
|
-
*/
|
|
128
|
-
unsubscribe(sessionId) {
|
|
129
|
-
this.subscriptions.delete(sessionId);
|
|
130
|
-
this.lastCheckpoint.delete(sessionId);
|
|
131
|
-
|
|
132
|
-
if (this.isConnected && this.ws?.readyState === WebSocket.OPEN) {
|
|
133
|
-
this.ws.send(JSON.stringify({
|
|
134
|
-
type: 'unsubscribe',
|
|
135
|
-
sessionId
|
|
136
|
-
}));
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Request recovery from a specific checkpoint
|
|
142
|
-
* Called when client detects missing data
|
|
143
|
-
*/
|
|
144
|
-
requestRecovery(sessionId) {
|
|
145
|
-
console.log(`[SyncManager] Requesting recovery for ${sessionId}`);
|
|
146
|
-
|
|
147
|
-
if (this.isConnected && this.ws?.readyState === WebSocket.OPEN) {
|
|
148
|
-
this.ws.send(JSON.stringify({
|
|
149
|
-
type: 'recovery_request',
|
|
150
|
-
sessionId
|
|
151
|
-
}));
|
|
152
|
-
} else {
|
|
153
|
-
// If not connected, recover when connection is restored
|
|
154
|
-
this.connect().then(() => {
|
|
155
|
-
this.ws.send(JSON.stringify({
|
|
156
|
-
type: 'recovery_request',
|
|
157
|
-
sessionId
|
|
158
|
-
}));
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Verify data consistency by querying server
|
|
165
|
-
*/
|
|
166
|
-
async validateSession(sessionId) {
|
|
167
|
-
const baseUrl = window.__BASE_URL || '/gm';
|
|
168
|
-
try {
|
|
169
|
-
const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/validate`);
|
|
170
|
-
const validation = await response.json();
|
|
171
|
-
return validation;
|
|
172
|
-
} catch (err) {
|
|
173
|
-
console.error(`[SyncManager] Validation failed: ${err.message}`);
|
|
174
|
-
return null;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Fetch full state for recovery
|
|
180
|
-
*/
|
|
181
|
-
async fetchSessionState(sessionId) {
|
|
182
|
-
const baseUrl = window.__BASE_URL || '/gm';
|
|
183
|
-
try {
|
|
184
|
-
const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/state-recovery`);
|
|
185
|
-
if (!response.ok) return null;
|
|
186
|
-
return await response.json();
|
|
187
|
-
} catch (err) {
|
|
188
|
-
console.error(`[SyncManager] Failed to fetch session state: ${err.message}`);
|
|
189
|
-
return null;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Automatic reconnection with exponential backoff
|
|
195
|
-
*/
|
|
196
|
-
attemptReconnect() {
|
|
197
|
-
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
198
|
-
console.error('[SyncManager] Max reconnection attempts reached');
|
|
199
|
-
this.emit('reconnect_failed');
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
this.reconnectAttempts++;
|
|
204
|
-
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
|
205
|
-
console.log(`[SyncManager] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
|
206
|
-
|
|
207
|
-
setTimeout(() => {
|
|
208
|
-
this.connect().catch(err => {
|
|
209
|
-
console.error('[SyncManager] Reconnection failed:', err);
|
|
210
|
-
this.attemptReconnect();
|
|
211
|
-
});
|
|
212
|
-
}, delay);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Detect missing updates by checking sequence gaps
|
|
217
|
-
*/
|
|
218
|
-
detectMissingUpdates(updates) {
|
|
219
|
-
const gaps = [];
|
|
220
|
-
for (let i = 0; i < updates.length - 1; i++) {
|
|
221
|
-
if (updates[i + 1].sequence !== updates[i].sequence + 1) {
|
|
222
|
-
gaps.push({
|
|
223
|
-
expected: updates[i].sequence + 1,
|
|
224
|
-
actual: updates[i + 1].sequence
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
return gaps;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Register event listener
|
|
233
|
-
*/
|
|
234
|
-
on(event, callback) {
|
|
235
|
-
if (!this.handlers.has(event)) {
|
|
236
|
-
this.handlers.set(event, []);
|
|
237
|
-
}
|
|
238
|
-
this.handlers.get(event).push(callback);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Emit event
|
|
243
|
-
*/
|
|
244
|
-
emit(event, data) {
|
|
245
|
-
const callbacks = this.handlers.get(event) || [];
|
|
246
|
-
for (const callback of callbacks) {
|
|
247
|
-
try {
|
|
248
|
-
callback(data);
|
|
249
|
-
} catch (err) {
|
|
250
|
-
console.error(`[SyncManager] Error in ${event} handler: ${err.message}`);
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Close connection gracefully
|
|
257
|
-
*/
|
|
258
|
-
disconnect() {
|
|
259
|
-
this.subscriptions.clear();
|
|
260
|
-
this.lastCheckpoint.clear();
|
|
261
|
-
if (this.ws) {
|
|
262
|
-
this.ws.close();
|
|
263
|
-
this.ws = null;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Export as global for browser use
|
|
269
|
-
if (typeof window !== 'undefined') {
|
|
270
|
-
window.SyncManager = SyncManager;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
export default SyncManager;
|
package/stream-handler.js
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import { queries } from './database.js';
|
|
2
|
-
import { StateValidator } from './state-validator.js';
|
|
3
|
-
|
|
4
|
-
export class StreamHandler {
|
|
5
|
-
constructor(sessionId, conversationId, broadcastFn) {
|
|
6
|
-
this.sessionId = sessionId;
|
|
7
|
-
this.conversationId = conversationId;
|
|
8
|
-
this.broadcastFn = broadcastFn;
|
|
9
|
-
this.updateCount = 0;
|
|
10
|
-
this.sequence = -1;
|
|
11
|
-
this.hasText = false;
|
|
12
|
-
this.hasBlocks = false;
|
|
13
|
-
this.blocks = [];
|
|
14
|
-
this.stateCheckpoint = StateValidator.getSessionState(sessionId);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
handleUpdate(params, baseUrl) {
|
|
18
|
-
const u = params.update;
|
|
19
|
-
if (!u) return;
|
|
20
|
-
|
|
21
|
-
const kind = u.sessionUpdate;
|
|
22
|
-
if (kind === 'agent_message_chunk' && u.content?.text) {
|
|
23
|
-
this.hasText = true;
|
|
24
|
-
const update = {
|
|
25
|
-
type: 'text',
|
|
26
|
-
content: u.content.text,
|
|
27
|
-
timestamp: Date.now()
|
|
28
|
-
};
|
|
29
|
-
this.persistAndBroadcast('text', update, baseUrl);
|
|
30
|
-
} else if (kind === 'html_content' && u.content?.html) {
|
|
31
|
-
this.hasBlocks = true;
|
|
32
|
-
const update = {
|
|
33
|
-
type: 'html',
|
|
34
|
-
html: u.content.html,
|
|
35
|
-
title: u.content.title,
|
|
36
|
-
id: u.content.id,
|
|
37
|
-
timestamp: Date.now()
|
|
38
|
-
};
|
|
39
|
-
this.blocks.push({ type: 'html', html: u.content.html, title: u.content.title, id: u.content.id });
|
|
40
|
-
this.persistAndBroadcast('html', update, baseUrl);
|
|
41
|
-
} else if (kind === 'image_content' && u.content?.path) {
|
|
42
|
-
this.hasBlocks = true;
|
|
43
|
-
const imageUrl = baseUrl + '/api/image/' + encodeURIComponent(u.content.path);
|
|
44
|
-
const update = {
|
|
45
|
-
type: 'image',
|
|
46
|
-
path: u.content.path,
|
|
47
|
-
url: imageUrl,
|
|
48
|
-
title: u.content.title,
|
|
49
|
-
alt: u.content.alt,
|
|
50
|
-
timestamp: Date.now()
|
|
51
|
-
};
|
|
52
|
-
this.blocks.push({ type: 'image', path: u.content.path, url: imageUrl, title: u.content.title, alt: u.content.alt });
|
|
53
|
-
this.persistAndBroadcast('image', update, baseUrl);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
persistAndBroadcast(updateType, update, baseUrl) {
|
|
58
|
-
try {
|
|
59
|
-
// CRITICAL: Database write MUST complete before broadcast
|
|
60
|
-
// This guarantees database is source of truth
|
|
61
|
-
const persistedUpdate = queries.createStreamUpdate(this.sessionId, this.conversationId, updateType, update);
|
|
62
|
-
this.sequence = persistedUpdate.sequence;
|
|
63
|
-
this.updateCount++;
|
|
64
|
-
|
|
65
|
-
// CRITICAL: Broadcast happens AFTER database write confirms
|
|
66
|
-
// This ensures clients see data that's already persisted
|
|
67
|
-
// Broadcast immediately with zero delay
|
|
68
|
-
this.broadcastFn({
|
|
69
|
-
type: 'stream_update',
|
|
70
|
-
sessionId: this.sessionId,
|
|
71
|
-
conversationId: this.conversationId,
|
|
72
|
-
updateType,
|
|
73
|
-
update: persistedUpdate.content,
|
|
74
|
-
sequence: this.sequence,
|
|
75
|
-
persisted: true,
|
|
76
|
-
timestamp: persistedUpdate.created_at
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
// Validate consistency asynchronously (don't block broadcast)
|
|
80
|
-
setImmediate(() => {
|
|
81
|
-
try {
|
|
82
|
-
const validation = StateValidator.validateSession(this.sessionId);
|
|
83
|
-
if (!validation.valid) {
|
|
84
|
-
console.error(`[StreamHandler] State validation failed: ${validation.error}`);
|
|
85
|
-
}
|
|
86
|
-
} catch (validationErr) {
|
|
87
|
-
console.error(`[StreamHandler] Validation error: ${validationErr.message}`);
|
|
88
|
-
}
|
|
89
|
-
});
|
|
90
|
-
} catch (err) {
|
|
91
|
-
console.error(`[StreamHandler] Error persisting update: ${err.message}`);
|
|
92
|
-
// On persistence failure, do NOT broadcast - maintain consistency
|
|
93
|
-
throw err;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
getBlocks() {
|
|
98
|
-
return this.blocks;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
getUpdateCount() {
|
|
102
|
-
return this.updateCount;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export default StreamHandler;
|