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 +0 -0
- package/acp-launcher.js +14 -51
- 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/01-initial-load.png +0 -0
- package/AUTOMATIC_IMPORT.md +0 -284
- package/CONVERSATION_DISPLAY_FIX.md +0 -125
- package/DEBUG_GUIDE.md +0 -136
- package/DIAGNOSTICS.md +0 -61
- package/FINAL_SUMMARY.md +0 -312
- package/IMPLEMENTATION_CHECKLIST.md +0 -287
- package/IMPLEMENTATION_STATUS.md +0 -189
- package/README.md +0 -213
- package/RECENT_UPDATES.md +0 -209
- package/REMOTE_DEBUG_GUIDE.md +0 -225
- package/RESPONSE_ISSUES.md +0 -157
- package/SIDEBAR_FIX_SUMMARY.md +0 -111
- package/STATE_CONSISTENCY_GUARANTEE.md +0 -183
- package/STATE_CONSISTENCY_TEST_INDEX.md +0 -268
- package/STATE_CONSISTENCY_TEST_REPORT.md +0 -256
- package/STATE_MACHINE_SUMMARY.md +0 -172
- package/TEST_README.md +0 -205
- package/TEST_SUMMARY.md +0 -159
- package/test-artifacts/01-window-a-initial.png +0 -0
- package/test-artifacts/01-window-b-initial.png +0 -0
- package/test-artifacts/02-window-a-after-send.png +0 -0
- package/test-artifacts/02-window-b-after-send.png +0 -0
- package/test-artifacts/snapshot-a-1.txt +0 -1
- package/test-artifacts/snapshot-b-1.txt +0 -1
- package/test-state-consistency.cjs +0 -239
- package/test-state-manager.js +0 -55
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.
|
|
144
|
-
console.
|
|
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
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
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;
|