agentgui 1.0.38 → 1.0.39

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/database.js CHANGED
@@ -89,6 +89,21 @@ function initSchema() {
89
89
  );
90
90
 
91
91
  CREATE INDEX IF NOT EXISTS idx_idempotency_created ON idempotencyKeys(created_at);
92
+
93
+ CREATE TABLE IF NOT EXISTS stream_updates (
94
+ id TEXT PRIMARY KEY,
95
+ sessionId TEXT NOT NULL,
96
+ conversationId TEXT NOT NULL,
97
+ updateType TEXT NOT NULL,
98
+ content TEXT NOT NULL,
99
+ sequence INTEGER NOT NULL,
100
+ created_at INTEGER NOT NULL,
101
+ FOREIGN KEY (sessionId) REFERENCES sessions(id),
102
+ FOREIGN KEY (conversationId) REFERENCES conversations(id)
103
+ );
104
+
105
+ CREATE INDEX IF NOT EXISTS idx_stream_updates_session ON stream_updates(sessionId);
106
+ CREATE INDEX IF NOT EXISTS idx_stream_updates_created ON stream_updates(created_at);
92
107
  `);
93
108
  }
94
109
 
@@ -596,6 +611,55 @@ export const queries = {
596
611
  }
597
612
 
598
613
  return imported;
614
+ },
615
+
616
+ createStreamUpdate(sessionId, conversationId, updateType, content) {
617
+ const id = generateId('upd');
618
+ const now = Date.now();
619
+
620
+ // Use transaction to ensure atomic sequence number assignment
621
+ const transaction = db.transaction(() => {
622
+ const maxSequence = db.prepare(
623
+ 'SELECT MAX(sequence) as max FROM stream_updates WHERE sessionId = ?'
624
+ ).get(sessionId);
625
+ const sequence = (maxSequence?.max || -1) + 1;
626
+
627
+ db.prepare(
628
+ `INSERT INTO stream_updates (id, sessionId, conversationId, updateType, content, sequence, created_at)
629
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
630
+ ).run(id, sessionId, conversationId, updateType, JSON.stringify(content), sequence, now);
631
+
632
+ return sequence;
633
+ });
634
+
635
+ const sequence = transaction();
636
+
637
+ return {
638
+ id,
639
+ sessionId,
640
+ conversationId,
641
+ updateType,
642
+ content,
643
+ sequence,
644
+ created_at: now
645
+ };
646
+ },
647
+
648
+ getSessionStreamUpdates(sessionId) {
649
+ const stmt = db.prepare(
650
+ `SELECT id, sessionId, conversationId, updateType, content, sequence, created_at
651
+ FROM stream_updates WHERE sessionId = ? ORDER BY sequence ASC`
652
+ );
653
+ const rows = stmt.all(sessionId);
654
+ return rows.map(row => ({
655
+ ...row,
656
+ content: JSON.parse(row.content)
657
+ }));
658
+ },
659
+
660
+ clearSessionStreamUpdates(sessionId) {
661
+ const stmt = db.prepare('DELETE FROM stream_updates WHERE sessionId = ?');
662
+ stmt.run(sessionId);
599
663
  }
600
664
  };
601
665
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.38",
3
+ "version": "1.0.39",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -10,6 +10,8 @@ import ACPConnection from './acp-launcher.js';
10
10
  import { ResponseFormatter } from './response-formatter.js';
11
11
  import { HTMLWrapper } from './html-wrapper.js';
12
12
  import { SessionStateStore } from './state-manager.js';
13
+ import { StreamHandler } from './stream-handler.js';
14
+ import { StateValidator } from './state-validator.js';
13
15
 
14
16
  // Debug logging to file
15
17
  const debugLog = (msg) => {
@@ -253,6 +255,38 @@ const server = http.createServer(async (req, res) => {
253
255
  return;
254
256
  }
255
257
 
258
+ const streamUpdatesMatch = routePath.match(/^\/api\/sessions\/([^/]+)\/stream-updates$/);
259
+ if (streamUpdatesMatch && req.method === 'GET') {
260
+ const sessionId = streamUpdatesMatch[1];
261
+ const updates = queries.getSessionStreamUpdates(sessionId);
262
+ res.writeHead(200, { 'Content-Type': 'application/json' });
263
+ res.end(JSON.stringify({ sessionId, updates, count: updates.length }));
264
+ return;
265
+ }
266
+
267
+ const stateRecoveryMatch = routePath.match(/^\/api\/sessions\/([^/]+)\/state-recovery$/);
268
+ if (stateRecoveryMatch && req.method === 'GET') {
269
+ const sessionId = stateRecoveryMatch[1];
270
+ const state = StateValidator.getSessionState(sessionId);
271
+ if (!state) {
272
+ res.writeHead(404, { 'Content-Type': 'application/json' });
273
+ res.end(JSON.stringify({ error: 'Session not found' }));
274
+ return;
275
+ }
276
+ res.writeHead(200, { 'Content-Type': 'application/json' });
277
+ res.end(JSON.stringify(state));
278
+ return;
279
+ }
280
+
281
+ const stateValidationMatch = routePath.match(/^\/api\/sessions\/([^/]+)\/validate$/);
282
+ if (stateValidationMatch && req.method === 'GET') {
283
+ const sessionId = stateValidationMatch[1];
284
+ const validation = StateValidator.validateSession(sessionId);
285
+ res.writeHead(200, { 'Content-Type': 'application/json' });
286
+ res.end(JSON.stringify(validation));
287
+ return;
288
+ }
289
+
256
290
  if (routePath === '/api/import/claude-code' && req.method === 'GET') {
257
291
  const result = queries.importClaudeCodeConversations();
258
292
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -360,13 +394,13 @@ function serveFile(filePath, res) {
360
394
  }
361
395
 
362
396
  /**
363
- * Process a user message through the Claude Code ACP with explicit state tracking
364
- * This is now fully predictable with no hidden failures
397
+ * Process a user message through the Claude Code ACP with real-time streaming
398
+ * Updates are persisted to database and broadcast to clients immediately
365
399
  */
366
400
  async function processMessage(conversationId, messageId, sessionId, content, agentId, folderContext) {
367
401
  // Create state manager for this session
368
402
  const stateManager = sessionStateStore.create(sessionId, conversationId, messageId, 120000);
369
-
403
+
370
404
  try {
371
405
  console.log(`[processMessage] Starting: conversationId=${conversationId}, sessionId=${sessionId}`);
372
406
  console.log(`[processMessage] Initial state: ${stateManager.getState()}`);
@@ -379,35 +413,26 @@ async function processMessage(conversationId, messageId, sessionId, content, age
379
413
 
380
414
  const cwd = folderContext?.path || '/config';
381
415
  const actualAgentId = agentId || 'claude-code';
382
-
416
+
383
417
  try {
384
418
  const conn = await getACP(actualAgentId, cwd);
385
-
419
+
386
420
  // STATE: ACQUIRING_ACP → ACP_ACQUIRED
387
421
  stateManager.transition(stateManager.constructor.STATES.ACP_ACQUIRED, {
388
422
  reason: 'ACP connection established',
389
423
  data: { acpConnectionTime: Date.now() }
390
424
  });
391
425
 
426
+ // Create stream handler for real-time persistence and broadcasting
427
+ const streamHandler = new StreamHandler(sessionId, conversationId, broadcastSync);
392
428
  let fullText = '';
393
- const blocks = [];
394
- const updateChunks = [];
395
429
 
396
- // Setup response accumulation
430
+ // Setup response streaming
397
431
  conn.onUpdate = (params) => {
432
+ streamHandler.handleUpdate(params, BASE_URL);
398
433
  const u = params.update;
399
- if (!u) return;
400
- const kind = u.sessionUpdate;
401
- if (kind === 'agent_message_chunk' && u.content?.text) {
434
+ if (u?.sessionUpdate === 'agent_message_chunk' && u.content?.text) {
402
435
  fullText += u.content.text;
403
- updateChunks.push({ type: 'text', content: u.content.text, timestamp: Date.now() });
404
- } else if (kind === 'html_content' && u.content?.html) {
405
- blocks.push({ type: 'html', html: u.content.html, title: u.content.title, id: u.content.id });
406
- updateChunks.push({ type: 'html', content: u.content.html, title: u.content.title, timestamp: Date.now() });
407
- } else if (kind === 'image_content' && u.content?.path) {
408
- const imageUrl = BASE_URL + '/api/image/' + encodeURIComponent(u.content.path);
409
- blocks.push({ type: 'image', path: u.content.path, url: imageUrl, title: u.content.title, alt: u.content.alt });
410
- updateChunks.push({ type: 'image', path: u.content.path, url: imageUrl, title: u.content.title, timestamp: Date.now() });
411
436
  }
412
437
  };
413
438
 
@@ -427,61 +452,64 @@ async function processMessage(conversationId, messageId, sessionId, content, age
427
452
  data: { promptSentTime: Date.now(), responseReceivedTime: Date.now() }
428
453
  });
429
454
 
430
- console.log(`[processMessage] ACP returned: stopReason=${result?.stopReason}, fullText=${fullText.length} chars`);
455
+ console.log(`[processMessage] ACP returned: stopReason=${result?.stopReason}, streamUpdates=${streamHandler.getUpdateCount()}`);
431
456
 
432
- // Format response
457
+ // Use full text if available, otherwise use result
433
458
  let responseText = fullText || result?.result || (result?.stopReason ? `Completed: ${result.stopReason}` : 'No response.');
434
-
435
- // Wrap response in HTML if needed
459
+
460
+ // Only wrap plain text in HTML - don't wrap if already HTML
436
461
  const isHTML = responseText.trim().startsWith('<');
437
462
  if (!isHTML) {
438
463
  responseText = HTMLWrapper.wrapResponse(responseText);
439
464
  }
440
-
465
+
441
466
  // Segment and format
442
467
  const segments = ResponseFormatter.segmentResponse(responseText);
443
468
  const metadata = ResponseFormatter.extractMetadata(responseText);
444
-
445
- const messageContent = blocks.length > 0 ? {
446
- text: responseText,
447
- blocks,
448
- segments,
449
- metadata,
450
- updateChunks,
451
- isHTML: true
452
- } : {
469
+ const blocks = streamHandler.getBlocks();
470
+
471
+ const messageContent = {
453
472
  text: responseText,
473
+ blocks: blocks.length > 0 ? blocks : undefined,
454
474
  segments,
455
475
  metadata,
456
- updateChunks,
476
+ streamUpdatesCount: streamHandler.getUpdateCount(),
457
477
  isHTML: true
458
478
  };
459
479
 
460
- // Save response to database
480
+ // Save consolidated response to database
461
481
  const assistantMessage = queries.createMessage(conversationId, 'assistant', messageContent);
462
- queries.updateSession(sessionId, { status: 'completed', response: { text: responseText, messageId: assistantMessage.id }, completed_at: Date.now() });
482
+ queries.updateSession(sessionId, {
483
+ status: 'completed',
484
+ response: { text: responseText, messageId: assistantMessage.id },
485
+ completed_at: Date.now()
486
+ });
463
487
  queries.createEvent('session.completed', { messageId: assistantMessage.id }, conversationId, sessionId);
464
488
 
465
- // Broadcast to connected clients
466
- broadcastSync({ type: 'session_updated', sessionId, status: 'completed', message: assistantMessage });
489
+ // Broadcast final consolidated response
490
+ broadcastSync({
491
+ type: 'session_updated',
492
+ sessionId,
493
+ status: 'completed',
494
+ message: assistantMessage
495
+ });
467
496
 
468
497
  // STATE: PROCESSING → COMPLETED
469
498
  stateManager.transition(stateManager.constructor.STATES.COMPLETED, {
470
499
  reason: 'Response successfully generated and saved',
471
500
  data: {
472
- fullText,
473
- blocks,
474
501
  responseLength: responseText.length,
475
- messageId: assistantMessage.id
502
+ messageId: assistantMessage.id,
503
+ streamUpdates: streamHandler.getUpdateCount()
476
504
  }
477
505
  });
478
506
 
479
- console.log(`[processMessage] ✅ Session completed: ${stateManager.getSummary().duration}`);
507
+ console.log(`[processMessage] ✅ Session completed with ${streamHandler.getUpdateCount()} stream updates: ${stateManager.getSummary().duration}`);
480
508
 
481
509
  } catch (acpError) {
482
510
  console.error(`[processMessage] ACP Error: ${acpError.message}`);
483
511
  console.error(`[processMessage] Stack: ${acpError.stack}`);
484
-
512
+
485
513
  // STATE: → ERROR
486
514
  stateManager.transition(stateManager.constructor.STATES.ERROR, {
487
515
  reason: `ACP error: ${acpError.message}`,
@@ -546,16 +574,70 @@ wss.on('connection', (ws, req) => {
546
574
  } else if (wsPath === '/sync') {
547
575
  syncClients.add(ws);
548
576
  ws.isAlive = true;
549
- ws.send(JSON.stringify({ type: 'sync_connected' }));
577
+ ws.subscriptions = new Set();
578
+ ws.clientId = `client-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
579
+
580
+ ws.send(JSON.stringify({
581
+ type: 'sync_connected',
582
+ clientId: ws.clientId,
583
+ timestamp: Date.now()
584
+ }));
585
+
586
+ ws.on('message', (msg) => {
587
+ try {
588
+ const data = JSON.parse(msg);
589
+ if (data.type === 'subscribe') {
590
+ ws.subscriptions.add(data.sessionId);
591
+ // On subscribe, send current state for recovery
592
+ const state = StateValidator.getSessionState(data.sessionId);
593
+ if (state) {
594
+ ws.send(JSON.stringify({
595
+ type: 'state_snapshot',
596
+ sessionId: data.sessionId,
597
+ state,
598
+ timestamp: Date.now()
599
+ }));
600
+ }
601
+ } else if (data.type === 'unsubscribe') {
602
+ ws.subscriptions.delete(data.sessionId);
603
+ } else if (data.type === 'recovery_request') {
604
+ // Client asking to recover from a checkpoint
605
+ const state = StateValidator.getSessionState(data.sessionId);
606
+ if (state) {
607
+ ws.send(JSON.stringify({
608
+ type: 'recovery_response',
609
+ sessionId: data.sessionId,
610
+ state,
611
+ timestamp: Date.now()
612
+ }));
613
+ }
614
+ }
615
+ } catch (e) {
616
+ console.error('WebSocket message parse error:', e.message);
617
+ }
618
+ });
619
+
550
620
  ws.on('pong', () => { ws.isAlive = true; });
551
- ws.on('close', () => { syncClients.delete(ws); });
621
+ ws.on('close', () => {
622
+ syncClients.delete(ws);
623
+ console.log(`[WebSocket] Client ${ws.clientId} disconnected`);
624
+ });
552
625
  }
553
626
  });
554
627
 
555
628
  function broadcastSync(event) {
556
629
  const data = JSON.stringify(event);
557
630
  for (const ws of syncClients) {
558
- if (ws.readyState === 1) ws.send(data);
631
+ if (ws.readyState === 1) {
632
+ // CRITICAL: Only send if client subscribed to this session
633
+ if (event.sessionId) {
634
+ if (!ws.subscriptions || !ws.subscriptions.has(event.sessionId)) {
635
+ continue;
636
+ }
637
+ }
638
+ // Send immediately - no buffering
639
+ ws.send(data);
640
+ }
559
641
  }
560
642
  }
561
643
 
@@ -0,0 +1,150 @@
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;
@@ -0,0 +1,273 @@
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;
@@ -0,0 +1,101 @@
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
+ // Validate consistency after write
66
+ const validation = StateValidator.validateSession(this.sessionId);
67
+ if (!validation.valid) {
68
+ console.error(`[StreamHandler] State validation failed after update:`, validation);
69
+ // Log but continue - database is still source of truth
70
+ }
71
+
72
+ // CRITICAL: Broadcast happens AFTER database write confirms
73
+ // This ensures clients see data that's already persisted
74
+ this.broadcastFn({
75
+ type: 'stream_update',
76
+ sessionId: this.sessionId,
77
+ conversationId: this.conversationId,
78
+ updateType,
79
+ update: persistedUpdate.content,
80
+ sequence: this.sequence,
81
+ persisted: true,
82
+ timestamp: persistedUpdate.created_at,
83
+ validation: validation.valid ? undefined : { error: validation.error }
84
+ });
85
+ } catch (err) {
86
+ console.error(`[StreamHandler] Error persisting update: ${err.message}`);
87
+ // On persistence failure, do NOT broadcast - maintain consistency
88
+ throw err;
89
+ }
90
+ }
91
+
92
+ getBlocks() {
93
+ return this.blocks;
94
+ }
95
+
96
+ getUpdateCount() {
97
+ return this.updateCount;
98
+ }
99
+ }
100
+
101
+ export default StreamHandler;