agentgui 1.0.38 → 1.0.40

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/CLAUDE.md ADDED
File without changes
package/acp-launcher.js CHANGED
@@ -116,7 +116,6 @@ export default class ACPConnection {
116
116
  this.pendingRequests = new Map();
117
117
  this.sessionId = null;
118
118
  this.onUpdate = null;
119
- this.printMode = false;
120
119
  this.cwd = '/config';
121
120
  }
122
121
 
@@ -140,18 +139,13 @@ export default class ACPConnection {
140
139
  await Promise.race([acpSetup(), deadline]);
141
140
  console.log(`[ACP] Connected via ACP bridge (${agentType})`);
142
141
  } catch (acpErr) {
143
- console.log(`[ACP] Bridge failed: ${acpErr.message}`);
144
- console.log(`[ACP] Falling back to claude --print mode`);
145
- this.printMode = true;
146
- this.sessionId = 'print-' + Date.now();
142
+ console.error(`[ACP] ❌ FATAL: Bridge failed: ${acpErr.message}`);
143
+ console.error(`[ACP] The ACP bridge is REQUIRED. Please install the bridge for ${agentType}.`);
147
144
  if (this.child) {
148
145
  try { this.child.kill('SIGTERM'); } catch (_) {}
149
146
  this.child = null;
150
147
  }
151
- for (const [id, req] of this.pendingRequests) {
152
- clearTimeout(req.timeoutId);
153
- }
154
- this.pendingRequests.clear();
148
+ throw acpErr;
155
149
  }
156
150
  }
157
151
 
@@ -294,7 +288,6 @@ export default class ACPConnection {
294
288
  }
295
289
 
296
290
  async initialize() {
297
- if (this.printMode) return {};
298
291
  return this.sendRequest('initialize', {
299
292
  protocolVersion: 1,
300
293
  clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
@@ -302,77 +295,47 @@ export default class ACPConnection {
302
295
  }
303
296
 
304
297
  async newSession(cwd) {
305
- if (this.printMode) {
306
- this.cwd = cwd;
307
- return { sessionId: this.sessionId };
308
- }
309
298
  const result = await this.sendRequest('session/new', { cwd, mcpServers: [] }, 120000);
310
299
  this.sessionId = result.sessionId;
311
300
  return result;
312
301
  }
313
302
 
314
303
  async setSessionMode(modeId) {
315
- if (this.printMode) return {};
316
304
  return this.sendRequest('session/set_mode', { sessionId: this.sessionId, modeId });
317
305
  }
318
306
 
319
307
  async injectSkills(additionalContext = '') {
320
- if (this.printMode) return {};
321
-
322
308
  // Combine the system prompt with any additional context
323
309
  const systemPrompt = additionalContext ? `${RIPPLEUI_SYSTEM_PROMPT}\n\n---\n\n${additionalContext}` : RIPPLEUI_SYSTEM_PROMPT;
324
-
325
- try {
326
- const result = await this.sendRequest('session/skill_inject', {
327
- sessionId: this.sessionId,
328
- skills: [],
329
- notification: [{ type: 'text', text: systemPrompt }]
330
- });
331
- return result;
332
- } catch (e) {
333
- // skill_inject may not be supported, try alternative methods
334
- console.log(`[ACP] skill_inject failed (may not be supported): ${e.message}`);
335
- return null;
336
- }
310
+
311
+ return this.sendRequest('session/skill_inject', {
312
+ sessionId: this.sessionId,
313
+ skills: [],
314
+ notification: [{ type: 'text', text: systemPrompt }]
315
+ });
337
316
  }
338
317
 
339
318
  /**
340
319
  * Inject system prompt as initial context
341
320
  */
342
321
  async injectSystemContext() {
343
- if (this.printMode) return {};
344
-
345
- // Some versions may need the system prompt sent as the first message context
346
- try {
347
- await this.sendRequest('session/context', {
348
- sessionId: this.sessionId,
349
- context: RIPPLEUI_SYSTEM_PROMPT,
350
- role: 'system'
351
- });
352
- return { success: true };
353
- } catch (e) {
354
- // This method may not be supported, silently fail
355
- return null;
356
- }
322
+ return this.sendRequest('session/context', {
323
+ sessionId: this.sessionId,
324
+ context: RIPPLEUI_SYSTEM_PROMPT,
325
+ role: 'system'
326
+ });
357
327
  }
358
328
 
359
329
  async sendPrompt(prompt) {
360
- if (this.printMode) return this._sendPrintPrompt(prompt);
361
330
  const promptContent = Array.isArray(prompt) ? prompt : [{ type: 'text', text: prompt }];
362
331
  return this.sendRequest('session/prompt', { sessionId: this.sessionId, prompt: promptContent }, 300000);
363
332
  }
364
333
 
365
- async _sendPrintPrompt(prompt) {
366
- throw new Error('Claude Code uses OAuth and requires the ACP bridge. The fallback to direct API calls is not supported because OAuth tokens cannot be used with the Anthropic API directly. Please ensure claude-code-acp is available in your PATH.');
367
- }
368
-
369
334
  isRunning() {
370
- if (this.printMode) return true;
371
335
  return this.child && !this.child.killed;
372
336
  }
373
337
 
374
338
  async terminate() {
375
- if (this.printMode) { this.printMode = false; return; }
376
339
  if (!this.child) return;
377
340
  this.child.stdin.end();
378
341
  this.child.kill('SIGTERM');
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.40",
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;