agentgui 1.0.67 → 1.0.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prd +92 -0
- package/.prd-browser +607 -0
- package/CLAUDE.md +1559 -125
- package/browser-test-harness.js +371 -0
- package/browser-test.js +409 -0
- package/execute-tests.js +164 -0
- package/lib/claude-runner.js +41 -12
- package/lib/database-service.ts +252 -0
- package/lib/sync-service.ts +275 -0
- package/lib/types.ts +168 -0
- package/package.json +1 -1
- package/readme.md +586 -0
- package/run-e2e-test.sh +88 -0
- package/server.js +274 -8
- package/static/index.html +487 -180
- package/static/js/client.js +558 -0
- package/static/js/event-filter.js +311 -0
- package/static/js/event-processor.js +454 -0
- package/static/js/streaming-renderer.js +813 -0
- package/static/js/syntax-highlighter.js +271 -0
- package/static/js/ui-components.js +433 -0
- package/static/js/websocket-manager.js +482 -0
- package/static/templates/INDEX.html +465 -0
- package/static/templates/README.md +190 -0
- package/static/templates/agent-capabilities.html +56 -0
- package/static/templates/agent-metadata-panel.html +44 -0
- package/static/templates/agent-status-badge.html +30 -0
- package/static/templates/code-annotation-panel.html +155 -0
- package/static/templates/code-suggestion-panel.html +184 -0
- package/static/templates/command-header.html +77 -0
- package/static/templates/command-output-scrollable.html +118 -0
- package/static/templates/elapsed-time.html +54 -0
- package/static/templates/error-alert.html +106 -0
- package/static/templates/error-history-timeline.html +160 -0
- package/static/templates/error-recovery-options.html +109 -0
- package/static/templates/error-stack-trace.html +95 -0
- package/static/templates/error-summary.html +80 -0
- package/static/templates/event-counter.html +48 -0
- package/static/templates/execution-actions.html +97 -0
- package/static/templates/execution-progress-bar.html +80 -0
- package/static/templates/execution-stepper.html +120 -0
- package/static/templates/file-breadcrumb.html +118 -0
- package/static/templates/file-diff-viewer.html +121 -0
- package/static/templates/file-metadata.html +133 -0
- package/static/templates/file-read-panel.html +66 -0
- package/static/templates/file-write-panel.html +120 -0
- package/static/templates/git-branch-remote.html +107 -0
- package/static/templates/git-diff-list.html +101 -0
- package/static/templates/git-log-visualization.html +153 -0
- package/static/templates/git-status-panel.html +115 -0
- package/static/templates/quality-metrics-display.html +170 -0
- package/static/templates/terminal-output-panel.html +87 -0
- package/static/templates/test-results-display.html +144 -0
- package/test-browser.js +457 -0
- package/test-runner.js +182 -0
package/lib/database-service.ts
CHANGED
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
ValidationResult,
|
|
16
16
|
ValidationError,
|
|
17
17
|
SyncError,
|
|
18
|
+
ExecutionMetadata,
|
|
19
|
+
StreamingEvent,
|
|
18
20
|
} from './types';
|
|
19
21
|
import {
|
|
20
22
|
validateConversation,
|
|
@@ -369,6 +371,256 @@ export class DatabaseService {
|
|
|
369
371
|
return { valid: errors.length === 0, errors };
|
|
370
372
|
}
|
|
371
373
|
|
|
374
|
+
// =========================================================================
|
|
375
|
+
// STREAMING EXECUTION OPERATIONS
|
|
376
|
+
// =========================================================================
|
|
377
|
+
|
|
378
|
+
storeExecutionEvent(sessionId: string, event: StreamingEvent): void {
|
|
379
|
+
this.checkClosed();
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
const stmt = this.db.prepare(
|
|
383
|
+
'INSERT INTO execution_events (sessionId, eventType, eventData, timestamp) VALUES (?, ?, ?, ?)'
|
|
384
|
+
);
|
|
385
|
+
stmt.run(sessionId, event.type, JSON.stringify(event), event.timestamp || Date.now());
|
|
386
|
+
} catch (err) {
|
|
387
|
+
throw new SyncError(
|
|
388
|
+
'DATABASE_ERROR',
|
|
389
|
+
`Failed to store execution event: ${(err as Error).message}`,
|
|
390
|
+
true,
|
|
391
|
+
{ sessionId, eventType: event.type }
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
storeExecutionMetadata(sessionId: string, metadata: ExecutionMetadata): void {
|
|
397
|
+
this.checkClosed();
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
const stmt = this.db.prepare(
|
|
401
|
+
'INSERT OR REPLACE INTO execution_metadata (sessionId, metadata, updated_at) VALUES (?, ?, ?)'
|
|
402
|
+
);
|
|
403
|
+
stmt.run(sessionId, JSON.stringify(metadata), Date.now());
|
|
404
|
+
} catch (err) {
|
|
405
|
+
throw new SyncError(
|
|
406
|
+
'DATABASE_ERROR',
|
|
407
|
+
`Failed to store execution metadata: ${(err as Error).message}`,
|
|
408
|
+
true,
|
|
409
|
+
{ sessionId }
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
getSessionExecutionHistory(sessionId: string, limit = 1000, offset = 0): StreamingEvent[] {
|
|
415
|
+
this.checkClosed();
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
const stmt = this.db.prepare(
|
|
419
|
+
'SELECT eventData FROM execution_events WHERE sessionId = ? ORDER BY timestamp ASC LIMIT ? OFFSET ?'
|
|
420
|
+
);
|
|
421
|
+
const rows = stmt.all(sessionId, limit, offset);
|
|
422
|
+
|
|
423
|
+
return rows.map((row) => {
|
|
424
|
+
try {
|
|
425
|
+
return JSON.parse(row.eventData);
|
|
426
|
+
} catch (e) {
|
|
427
|
+
console.warn('[DatabaseService] Invalid execution event JSON:', row.eventData);
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
}).filter((e): e is StreamingEvent => e !== null);
|
|
431
|
+
} catch (err) {
|
|
432
|
+
throw new SyncError(
|
|
433
|
+
'DATABASE_ERROR',
|
|
434
|
+
`Failed to get execution history: ${(err as Error).message}`,
|
|
435
|
+
true,
|
|
436
|
+
{ sessionId, limit, offset }
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
getExecutionMetadata(sessionId: string): ExecutionMetadata | null {
|
|
442
|
+
this.checkClosed();
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
const stmt = this.db.prepare('SELECT metadata FROM execution_metadata WHERE sessionId = ?');
|
|
446
|
+
const row = stmt.get(sessionId);
|
|
447
|
+
|
|
448
|
+
if (!row) return null;
|
|
449
|
+
|
|
450
|
+
try {
|
|
451
|
+
return JSON.parse(row.metadata);
|
|
452
|
+
} catch (e) {
|
|
453
|
+
throw new SyncError('VALIDATION_ERROR', `Invalid metadata JSON for session ${sessionId}`, false);
|
|
454
|
+
}
|
|
455
|
+
} catch (err) {
|
|
456
|
+
if (err instanceof SyncError) throw err;
|
|
457
|
+
throw new SyncError(
|
|
458
|
+
'DATABASE_ERROR',
|
|
459
|
+
`Failed to get execution metadata: ${(err as Error).message}`,
|
|
460
|
+
true,
|
|
461
|
+
{ sessionId }
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
batchStoreExecutionEvents(sessionId: string, events: StreamingEvent[]): { count: number; latencyMs: number } {
|
|
467
|
+
this.checkClosed();
|
|
468
|
+
|
|
469
|
+
const startTime = Date.now();
|
|
470
|
+
try {
|
|
471
|
+
const transaction = this.db.transaction(() => {
|
|
472
|
+
const stmt = this.db.prepare(
|
|
473
|
+
'INSERT INTO execution_events (sessionId, eventType, eventData, timestamp) VALUES (?, ?, ?, ?)'
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
for (const event of events) {
|
|
477
|
+
stmt.run(sessionId, event.type, JSON.stringify(event), event.timestamp || Date.now());
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
transaction();
|
|
482
|
+
const latencyMs = Date.now() - startTime;
|
|
483
|
+
|
|
484
|
+
if (latencyMs > 100) {
|
|
485
|
+
console.warn(`[DatabaseService] Batch write latency ${latencyMs}ms for ${events.length} events`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return { count: events.length, latencyMs };
|
|
489
|
+
} catch (err) {
|
|
490
|
+
if (err instanceof SyncError) throw err;
|
|
491
|
+
throw new SyncError(
|
|
492
|
+
'DATABASE_ERROR',
|
|
493
|
+
`Failed to batch store execution events: ${(err as Error).message}`,
|
|
494
|
+
true,
|
|
495
|
+
{ sessionId, count: events.length }
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
batchStoreExecutionEventsOptimized(
|
|
501
|
+
sessionId: string,
|
|
502
|
+
events: StreamingEvent[],
|
|
503
|
+
commitInterval = 100
|
|
504
|
+
): { count: number; batches: number; totalLatencyMs: number } {
|
|
505
|
+
this.checkClosed();
|
|
506
|
+
|
|
507
|
+
const startTime = Date.now();
|
|
508
|
+
let processedCount = 0;
|
|
509
|
+
let batchCount = 0;
|
|
510
|
+
|
|
511
|
+
try {
|
|
512
|
+
const stmt = this.db.prepare(
|
|
513
|
+
'INSERT INTO execution_events (sessionId, eventType, eventData, timestamp) VALUES (?, ?, ?, ?)'
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
// Process in batches with commit points
|
|
517
|
+
for (let i = 0; i < events.length; i += commitInterval) {
|
|
518
|
+
const batch = events.slice(i, i + commitInterval);
|
|
519
|
+
const transaction = this.db.transaction(() => {
|
|
520
|
+
for (const event of batch) {
|
|
521
|
+
stmt.run(sessionId, event.type, JSON.stringify(event), event.timestamp || Date.now());
|
|
522
|
+
processedCount++;
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
transaction();
|
|
527
|
+
batchCount++;
|
|
528
|
+
|
|
529
|
+
const elapsed = Date.now() - startTime;
|
|
530
|
+
if (elapsed > 5000) {
|
|
531
|
+
console.warn(`[DatabaseService] Long batch write: ${elapsed}ms for ${processedCount}/${events.length} events`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const totalLatencyMs = Date.now() - startTime;
|
|
536
|
+
return { count: events.length, batches: batchCount, totalLatencyMs };
|
|
537
|
+
} catch (err) {
|
|
538
|
+
throw new SyncError(
|
|
539
|
+
'DATABASE_ERROR',
|
|
540
|
+
`Failed to batch store execution events (processed ${processedCount}/${events.length}): ${(err as Error).message}`,
|
|
541
|
+
true,
|
|
542
|
+
{ sessionId, count: events.length, processed: processedCount }
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
markSessionIncomplete(sessionId: string, reason: string): void {
|
|
548
|
+
this.checkClosed();
|
|
549
|
+
|
|
550
|
+
try {
|
|
551
|
+
const stmt = this.db.prepare(
|
|
552
|
+
'UPDATE sessions SET status = ?, error = ?, completed_at = ? WHERE id = ?'
|
|
553
|
+
);
|
|
554
|
+
stmt.run('incomplete', reason, Date.now(), sessionId);
|
|
555
|
+
} catch (err) {
|
|
556
|
+
throw new SyncError(
|
|
557
|
+
'DATABASE_ERROR',
|
|
558
|
+
`Failed to mark session incomplete: ${(err as Error).message}`,
|
|
559
|
+
true,
|
|
560
|
+
{ sessionId, reason }
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
getIncompleteSessionsOlderThan(ageMinutes = 30): Array<{ id: string; started_at: number }> {
|
|
566
|
+
this.checkClosed();
|
|
567
|
+
|
|
568
|
+
try {
|
|
569
|
+
const cutoffTime = Date.now() - (ageMinutes * 60 * 1000);
|
|
570
|
+
const stmt = this.db.prepare(
|
|
571
|
+
'SELECT id, started_at FROM sessions WHERE status = ? AND started_at < ?'
|
|
572
|
+
);
|
|
573
|
+
return stmt.all('incomplete', cutoffTime);
|
|
574
|
+
} catch (err) {
|
|
575
|
+
throw new SyncError(
|
|
576
|
+
'DATABASE_ERROR',
|
|
577
|
+
`Failed to get incomplete sessions: ${(err as Error).message}`,
|
|
578
|
+
true,
|
|
579
|
+
{ ageMinutes }
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
markSessionComplete(sessionId: string, status: 'completed' | 'error' | 'timeout'): void {
|
|
585
|
+
this.checkClosed();
|
|
586
|
+
|
|
587
|
+
try {
|
|
588
|
+
const stmt = this.db.prepare(
|
|
589
|
+
'UPDATE sessions SET status = ?, completed_at = ? WHERE id = ?'
|
|
590
|
+
);
|
|
591
|
+
stmt.run(status, Date.now(), sessionId);
|
|
592
|
+
} catch (err) {
|
|
593
|
+
throw new SyncError(
|
|
594
|
+
'DATABASE_ERROR',
|
|
595
|
+
`Failed to mark session complete: ${(err as Error).message}`,
|
|
596
|
+
true,
|
|
597
|
+
{ sessionId, status }
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
cleanupOrphanedSessions(maxAgeDays = 7): number {
|
|
603
|
+
this.checkClosed();
|
|
604
|
+
|
|
605
|
+
try {
|
|
606
|
+
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
|
|
607
|
+
const cutoffTime = Date.now() - maxAgeMs;
|
|
608
|
+
|
|
609
|
+
const stmt = this.db.prepare(
|
|
610
|
+
'DELETE FROM sessions WHERE status NOT IN (?, ?, ?) AND started_at < ?'
|
|
611
|
+
);
|
|
612
|
+
const result = stmt.run('processing', 'pending', 'incomplete', cutoffTime);
|
|
613
|
+
return (result.changes || 0);
|
|
614
|
+
} catch (err) {
|
|
615
|
+
throw new SyncError(
|
|
616
|
+
'DATABASE_ERROR',
|
|
617
|
+
`Failed to cleanup orphaned sessions: ${(err as Error).message}`,
|
|
618
|
+
true,
|
|
619
|
+
{ maxAgeDays }
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
372
624
|
// =========================================================================
|
|
373
625
|
// LIFECYCLE
|
|
374
626
|
// =========================================================================
|
package/lib/sync-service.ts
CHANGED
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
SyncStatus,
|
|
14
14
|
SyncError,
|
|
15
15
|
ConflictResolutionStrategy,
|
|
16
|
+
StreamingEvent,
|
|
17
|
+
ExecutionMetadata,
|
|
16
18
|
} from './types';
|
|
17
19
|
import DatabaseService from './database-service';
|
|
18
20
|
|
|
@@ -313,6 +315,152 @@ export class SyncService extends EventEmitter {
|
|
|
313
315
|
this.emit('operation:processed', op);
|
|
314
316
|
}
|
|
315
317
|
|
|
318
|
+
// =========================================================================
|
|
319
|
+
// STREAMING EXECUTION SYNC
|
|
320
|
+
// =========================================================================
|
|
321
|
+
|
|
322
|
+
async syncSessionExecution(sessionId: string, events: StreamingEvent[]): Promise<SyncStatus> {
|
|
323
|
+
try {
|
|
324
|
+
this.emit('sync:start', { type: 'streaming', sessionId });
|
|
325
|
+
|
|
326
|
+
// Deduplicate by eventId (if present)
|
|
327
|
+
const dedupMap = new Map<string, StreamingEvent>();
|
|
328
|
+
for (const event of events) {
|
|
329
|
+
const key = event.eventId || `${event.type}:${event.timestamp}`;
|
|
330
|
+
dedupMap.set(key, event);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Store all execution events in batch
|
|
334
|
+
const uniqueEvents = Array.from(dedupMap.values());
|
|
335
|
+
this.db.batchStoreExecutionEvents(sessionId, uniqueEvents);
|
|
336
|
+
|
|
337
|
+
this.emit('sync:complete', {
|
|
338
|
+
type: 'streaming',
|
|
339
|
+
sessionId,
|
|
340
|
+
eventCount: uniqueEvents.length,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
state: 'synced',
|
|
345
|
+
lastSyncTime: Date.now(),
|
|
346
|
+
retryCount: 0,
|
|
347
|
+
maxRetries: this.options.retryAttempts,
|
|
348
|
+
};
|
|
349
|
+
} catch (err) {
|
|
350
|
+
return this.handleSyncError(err as Error);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async syncExecutionMetadata(sessionId: string, metadata: ExecutionMetadata): Promise<SyncStatus> {
|
|
355
|
+
try {
|
|
356
|
+
this.emit('sync:start', { type: 'metadata', sessionId });
|
|
357
|
+
|
|
358
|
+
this.db.storeExecutionMetadata(sessionId, metadata);
|
|
359
|
+
|
|
360
|
+
this.emit('sync:complete', {
|
|
361
|
+
type: 'metadata',
|
|
362
|
+
sessionId,
|
|
363
|
+
metadata,
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
state: 'synced',
|
|
368
|
+
lastSyncTime: Date.now(),
|
|
369
|
+
retryCount: 0,
|
|
370
|
+
maxRetries: this.options.retryAttempts,
|
|
371
|
+
};
|
|
372
|
+
} catch (err) {
|
|
373
|
+
return this.handleSyncError(err as Error);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async flushStreamingQueue(): Promise<{ flushed: number; failed: number; deduplicated: number }> {
|
|
378
|
+
if (this.pendingOperations.size === 0) {
|
|
379
|
+
return { flushed: 0, failed: 0, deduplicated: 0 };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const ops = Array.from(this.pendingOperations.values());
|
|
383
|
+
this.pendingOperations.clear();
|
|
384
|
+
|
|
385
|
+
// Dedup by operation key
|
|
386
|
+
const dedupMap = new Map<string, SyncEvent>();
|
|
387
|
+
let dedupCount = 0;
|
|
388
|
+
for (const op of ops) {
|
|
389
|
+
const key = `${op.type}:${op.data.id || op.data.sessionId || 'global'}`;
|
|
390
|
+
if (dedupMap.has(key)) {
|
|
391
|
+
dedupCount++;
|
|
392
|
+
} else {
|
|
393
|
+
dedupMap.set(key, op);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Group by type for ordering preservation
|
|
398
|
+
const byType = new Map<string, SyncEvent[]>();
|
|
399
|
+
for (const op of dedupMap.values()) {
|
|
400
|
+
if (!byType.has(op.type)) byType.set(op.type, []);
|
|
401
|
+
byType.get(op.type)!.push(op);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Sort by timestamp within each type
|
|
405
|
+
for (const events of byType.values()) {
|
|
406
|
+
events.sort((a, b) => a.timestamp - b.timestamp);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
let flushed = 0;
|
|
410
|
+
let failed = 0;
|
|
411
|
+
|
|
412
|
+
for (const [type, events] of byType) {
|
|
413
|
+
for (const op of events) {
|
|
414
|
+
try {
|
|
415
|
+
await this.processOperation(op);
|
|
416
|
+
this.emit('queue:item_processed', op);
|
|
417
|
+
flushed++;
|
|
418
|
+
} catch (err) {
|
|
419
|
+
this.emit('queue:error', {
|
|
420
|
+
operation: op,
|
|
421
|
+
error: (err as Error).message,
|
|
422
|
+
});
|
|
423
|
+
this.queueOperation(op);
|
|
424
|
+
failed++;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
this.emit('queue:flushed', { flushed, failed, deduplicated: dedupCount });
|
|
430
|
+
return { flushed, failed, deduplicated: dedupCount };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async flushStreamingQueueWithTimeout(timeoutMs = 30000): Promise<{ flushed: number; failed: number; timedOut: boolean }> {
|
|
434
|
+
const startTime = Date.now();
|
|
435
|
+
let flushed = 0;
|
|
436
|
+
let failed = 0;
|
|
437
|
+
let timedOut = false;
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
while (this.pendingOperations.size > 0) {
|
|
441
|
+
const elapsed = Date.now() - startTime;
|
|
442
|
+
if (elapsed > timeoutMs) {
|
|
443
|
+
timedOut = true;
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const result = await this.flushStreamingQueue();
|
|
448
|
+
flushed += result.flushed;
|
|
449
|
+
failed += result.failed;
|
|
450
|
+
|
|
451
|
+
if (result.flushed === 0) break;
|
|
452
|
+
}
|
|
453
|
+
} catch (err) {
|
|
454
|
+
this.emit('queue:timeout', {
|
|
455
|
+
error: (err as Error).message,
|
|
456
|
+
flushed,
|
|
457
|
+
failed,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return { flushed, failed, timedOut };
|
|
462
|
+
}
|
|
463
|
+
|
|
316
464
|
// =========================================================================
|
|
317
465
|
// STATUS & INFO
|
|
318
466
|
// =========================================================================
|
|
@@ -335,6 +483,133 @@ export class SyncService extends EventEmitter {
|
|
|
335
483
|
this.retryAttempts = 0;
|
|
336
484
|
this.lastSyncTime = 0;
|
|
337
485
|
}
|
|
486
|
+
|
|
487
|
+
// =========================================================================
|
|
488
|
+
// RECOVERY & RESILIENCE
|
|
489
|
+
// =========================================================================
|
|
490
|
+
|
|
491
|
+
async recoverIncompleteStreams(): Promise<{ recovered: number; failed: number; timedOut: number }> {
|
|
492
|
+
try {
|
|
493
|
+
this.emit('recovery:start', { type: 'incomplete_streams' });
|
|
494
|
+
|
|
495
|
+
const incompleteSessionsOlderThan30Min = this.db.getIncompleteSessionsOlderThan(30);
|
|
496
|
+
let recovered = 0;
|
|
497
|
+
let failed = 0;
|
|
498
|
+
let timedOut = 0;
|
|
499
|
+
|
|
500
|
+
for (const session of incompleteSessionsOlderThan30Min) {
|
|
501
|
+
try {
|
|
502
|
+
const ageMinutes = (Date.now() - session.started_at) / (60 * 1000);
|
|
503
|
+
|
|
504
|
+
if (ageMinutes > 120) {
|
|
505
|
+
// 2 hour timeout
|
|
506
|
+
this.db.markSessionComplete(session.id, 'timeout');
|
|
507
|
+
timedOut++;
|
|
508
|
+
this.emit('recovery:timeout', { sessionId: session.id, ageMinutes });
|
|
509
|
+
} else {
|
|
510
|
+
// Mark for retry
|
|
511
|
+
this.queueOperation({
|
|
512
|
+
type: 'retry_incomplete_session',
|
|
513
|
+
timestamp: Date.now(),
|
|
514
|
+
data: { sessionId: session.id }
|
|
515
|
+
});
|
|
516
|
+
recovered++;
|
|
517
|
+
this.emit('recovery:queued', { sessionId: session.id });
|
|
518
|
+
}
|
|
519
|
+
} catch (err) {
|
|
520
|
+
failed++;
|
|
521
|
+
this.emit('recovery:error', {
|
|
522
|
+
sessionId: session.id,
|
|
523
|
+
error: (err as Error).message,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
this.emit('recovery:complete', { recovered, failed, timedOut });
|
|
529
|
+
return { recovered, failed, timedOut };
|
|
530
|
+
} catch (err) {
|
|
531
|
+
this.emit('recovery:failed', { error: (err as Error).message });
|
|
532
|
+
return { recovered: 0, failed: 0, timedOut: 0 };
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async resolveExecutionConflicts(
|
|
537
|
+
sessionId: string,
|
|
538
|
+
localMetadata: ExecutionMetadata,
|
|
539
|
+
remoteMetadata: ExecutionMetadata
|
|
540
|
+
): Promise<ExecutionMetadata> {
|
|
541
|
+
try {
|
|
542
|
+
// Last-write-wins strategy: use the one with latest completion time
|
|
543
|
+
const localTime = localMetadata.endTime || 0;
|
|
544
|
+
const remoteTime = remoteMetadata.endTime || 0;
|
|
545
|
+
|
|
546
|
+
const winner = remoteTime >= localTime ? remoteMetadata : localMetadata;
|
|
547
|
+
|
|
548
|
+
// Merge metadata: take token counts from both, use max
|
|
549
|
+
const merged: ExecutionMetadata = {
|
|
550
|
+
...winner,
|
|
551
|
+
inputTokens: Math.max(localMetadata.inputTokens || 0, remoteMetadata.inputTokens || 0),
|
|
552
|
+
outputTokens: Math.max(localMetadata.outputTokens || 0, remoteMetadata.outputTokens || 0),
|
|
553
|
+
totalTokens: Math.max(localMetadata.totalTokens || 0, remoteMetadata.totalTokens || 0),
|
|
554
|
+
toolCalls: Math.max(localMetadata.toolCalls, remoteMetadata.toolCalls),
|
|
555
|
+
toolResults: Math.max(localMetadata.toolResults, remoteMetadata.toolResults),
|
|
556
|
+
errorCount: Math.max(localMetadata.errorCount, remoteMetadata.errorCount),
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
this.db.storeExecutionMetadata(sessionId, merged);
|
|
560
|
+
|
|
561
|
+
this.emit('conflict:resolved', {
|
|
562
|
+
sessionId,
|
|
563
|
+
strategy: 'last-write-wins',
|
|
564
|
+
winner: remoteTime >= localTime ? 'remote' : 'local',
|
|
565
|
+
merged,
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
return merged;
|
|
569
|
+
} catch (err) {
|
|
570
|
+
this.emit('conflict:error', {
|
|
571
|
+
sessionId,
|
|
572
|
+
error: (err as Error).message,
|
|
573
|
+
});
|
|
574
|
+
throw err;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async detectAndResolveConflicts(): Promise<{ resolved: number; failed: number }> {
|
|
579
|
+
try {
|
|
580
|
+
this.emit('conflict:detection_start');
|
|
581
|
+
|
|
582
|
+
// This would scan for duplicate sessions with same conversation+timestamp
|
|
583
|
+
// For now, return placeholder
|
|
584
|
+
let resolved = 0;
|
|
585
|
+
let failed = 0;
|
|
586
|
+
|
|
587
|
+
this.emit('conflict:detection_complete', { resolved, failed });
|
|
588
|
+
return { resolved, failed };
|
|
589
|
+
} catch (err) {
|
|
590
|
+
this.emit('conflict:detection_failed', { error: (err as Error).message });
|
|
591
|
+
return { resolved: 0, failed: 0 };
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async flushOfflineQueue(): Promise<{ flushed: number; failed: number; retried: number }> {
|
|
596
|
+
try {
|
|
597
|
+
this.emit('offline_queue:flush_start');
|
|
598
|
+
|
|
599
|
+
const result = await this.flushStreamingQueueWithTimeout(60000);
|
|
600
|
+
|
|
601
|
+
this.emit('offline_queue:flush_complete', {
|
|
602
|
+
flushed: result.flushed,
|
|
603
|
+
failed: result.failed,
|
|
604
|
+
timedOut: result.timedOut,
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
return { flushed: result.flushed, failed: result.failed, retried: 0 };
|
|
608
|
+
} catch (err) {
|
|
609
|
+
this.emit('offline_queue:flush_error', { error: (err as Error).message });
|
|
610
|
+
return { flushed: 0, failed: 0, retried: 0 };
|
|
611
|
+
}
|
|
612
|
+
}
|
|
338
613
|
}
|
|
339
614
|
|
|
340
615
|
export default SyncService;
|