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 +64 -0
- package/package.json +1 -1
- package/server.js +128 -46
- package/state-validator.js +150 -0
- package/static/sync-manager.js +273 -0
- package/stream-handler.js +101 -0
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
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
|
|
364
|
-
*
|
|
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
|
|
430
|
+
// Setup response streaming
|
|
397
431
|
conn.onUpdate = (params) => {
|
|
432
|
+
streamHandler.handleUpdate(params, BASE_URL);
|
|
398
433
|
const u = params.update;
|
|
399
|
-
if (
|
|
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},
|
|
455
|
+
console.log(`[processMessage] ACP returned: stopReason=${result?.stopReason}, streamUpdates=${streamHandler.getUpdateCount()}`);
|
|
431
456
|
|
|
432
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
446
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
|
466
|
-
broadcastSync({
|
|
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.
|
|
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', () => {
|
|
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)
|
|
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;
|