Fix project isolation: Make loadChatHistory respect active project sessions
- Modified loadChatHistory() to check for active project before fetching all sessions - When active project exists, use project.sessions instead of fetching from API - Added detailed console logging to debug session filtering - This prevents ALL sessions from appearing in every project's sidebar Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
264
services/chat-monitor.js
Normal file
264
services/chat-monitor.js
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Real-Time Chat Monitor Service
|
||||
*
|
||||
* Monitors agentic chat behavior and detects failures in real-time.
|
||||
* Triggers auto-fix pipeline when issues are detected.
|
||||
*/
|
||||
|
||||
const EventEmitter = require('events');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
class ChatMonitorService extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.activeMonitors = new Map();
|
||||
this.logsPath = path.join(__dirname, '../logs/chat-monitor');
|
||||
this.ensureLogsDirectory();
|
||||
this.startAutoFixListener();
|
||||
}
|
||||
|
||||
ensureLogsDirectory() {
|
||||
if (!fs.existsSync(this.logsPath)) {
|
||||
fs.mkdirSync(this.logsPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start monitoring a session
|
||||
*/
|
||||
startSessionMonitor(sessionId) {
|
||||
if (this.activeMonitors.has(sessionId)) {
|
||||
console.log(`[ChatMonitor] Session ${sessionId} already monitored`);
|
||||
return;
|
||||
}
|
||||
|
||||
const monitor = {
|
||||
sessionId,
|
||||
startTime: Date.now(),
|
||||
events: [],
|
||||
lastActivity: Date.now(),
|
||||
state: 'monitoring'
|
||||
};
|
||||
|
||||
this.activeMonitors.set(sessionId, monitor);
|
||||
console.log(`[ChatMonitor] Started monitoring session ${sessionId}`);
|
||||
|
||||
// Set up timeout for response detection
|
||||
this.setupResponseTimeout(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an event for a session
|
||||
*/
|
||||
logEvent(sessionId, eventType, data) {
|
||||
const monitor = this.activeMonitors.get(sessionId);
|
||||
if (!monitor) {
|
||||
this.startSessionMonitor(sessionId);
|
||||
return this.logEvent(sessionId, eventType, data);
|
||||
}
|
||||
|
||||
const event = {
|
||||
timestamp: Date.now(),
|
||||
eventType,
|
||||
data,
|
||||
sessionId
|
||||
};
|
||||
|
||||
monitor.events.push(event);
|
||||
monitor.lastActivity = Date.now();
|
||||
|
||||
// Write to log file
|
||||
this.writeLog(sessionId, event);
|
||||
|
||||
// Detect failures based on event type
|
||||
this.detectFailures(sessionId, event);
|
||||
|
||||
console.log(`[ChatMonitor] [${sessionId}] ${eventType}:`, JSON.stringify(data).substring(0, 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Write event to log file
|
||||
*/
|
||||
writeLog(sessionId, event) {
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
const logFile = path.join(this.logsPath, `${date}-${sessionId}.log`);
|
||||
|
||||
const logLine = `[${new Date(event.timestamp).toISOString()}] [${event.eventType}] ${JSON.stringify(event.data)}\n`;
|
||||
|
||||
fs.appendFileSync(logFile, logLine, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up response timeout detection
|
||||
*/
|
||||
setupResponseTimeout(sessionId) {
|
||||
// Check for response after 30 seconds
|
||||
setTimeout(() => {
|
||||
this.checkResponseReceived(sessionId);
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if response was received
|
||||
*/
|
||||
checkResponseReceived(sessionId) {
|
||||
const monitor = this.activeMonitors.get(sessionId);
|
||||
if (!monitor) return;
|
||||
|
||||
const hasUserMessage = monitor.events.some(e => e.eventType === 'user_message');
|
||||
const hasAIResponse = monitor.events.some(e => e.eventType === 'ai_response');
|
||||
|
||||
if (hasUserMessage && !hasAIResponse) {
|
||||
this.logEvent(sessionId, 'failure_detected', {
|
||||
reason: 'no_ai_response',
|
||||
message: 'User message sent but no AI response received within 30 seconds'
|
||||
});
|
||||
|
||||
this.emit('chat-failure', {
|
||||
sessionId,
|
||||
failureType: 'no_response',
|
||||
events: monitor.events
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect failures based on events
|
||||
*/
|
||||
detectFailures(sessionId, event) {
|
||||
switch (event.eventType) {
|
||||
case 'user_message_sent':
|
||||
// Expect AI response within 30 seconds
|
||||
break;
|
||||
|
||||
case 'claude_spawn_error':
|
||||
this.emit('chat-failure', {
|
||||
sessionId,
|
||||
failureType: 'claude_spawn_failed',
|
||||
error: event.data.error
|
||||
});
|
||||
break;
|
||||
|
||||
case 'json_parse_error':
|
||||
this.emit('chat-failure', {
|
||||
sessionId,
|
||||
failureType: 'json_parse_failed',
|
||||
error: event.data.error
|
||||
});
|
||||
break;
|
||||
|
||||
case 'sse_emit_error':
|
||||
this.emit('chat-failure', {
|
||||
sessionId,
|
||||
failureType: 'sse_emit_failed',
|
||||
error: event.data.error
|
||||
});
|
||||
break;
|
||||
|
||||
case 'browser_error':
|
||||
// Analyze browser errors - only trigger on actual errors, not info logs
|
||||
const msg = event.data.message || '';
|
||||
const type = event.data.type || '';
|
||||
|
||||
// Only trigger on actual error types or SSE connection failures
|
||||
const isError = type === 'console-error' ||
|
||||
type === 'console-warn' ||
|
||||
type === 'uncaughterror' ||
|
||||
msg.includes('EventSource failed') ||
|
||||
msg.includes('SSE connection failed') ||
|
||||
msg.includes('Connection lost') ||
|
||||
msg.includes('Failed to connect');
|
||||
|
||||
// Don't trigger on informational SSE logs (these indicate SSE is working!)
|
||||
if (isError && (msg.includes('SSE') || msg.includes('EventSource'))) {
|
||||
this.emit('chat-failure', {
|
||||
sessionId,
|
||||
failureType: 'browser_sse_error',
|
||||
error: event.data.message
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto-fix listener
|
||||
*/
|
||||
startAutoFixListener() {
|
||||
this.on('chat-failure', async (failure) => {
|
||||
console.log(`[ChatMonitor] 💥 Failure detected in session ${failure.sessionId}:`, failure.failureType);
|
||||
this.logEvent(failure.sessionId, 'auto_fix_triggered', failure);
|
||||
|
||||
// Trigger auto-fix agent
|
||||
await this.triggerAutoFix(failure);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger auto-fix agent
|
||||
*/
|
||||
async triggerAutoFix(failure) {
|
||||
console.log(`[ChatMonitor] 🔧 Triggering auto-fix for ${failure.failureType}`);
|
||||
|
||||
// Write failure to file for auto-fix agent to process
|
||||
const failureFile = path.join(this.logsPath, `failure-${Date.now()}.json`);
|
||||
fs.writeFileSync(failureFile, JSON.stringify(failure, null, 2));
|
||||
|
||||
// Launch auto-fix via background process
|
||||
const { spawn } = require('child_process');
|
||||
const autoFix = spawn('node', [__dirname + '../scripts/auto-fix.js', failureFile], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
});
|
||||
autoFix.unref();
|
||||
|
||||
console.log(`[ChatMonitor] 🚀 Auto-fix agent launched: ${autoFix.pid}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop monitoring a session
|
||||
*/
|
||||
stopSessionMonitor(sessionId) {
|
||||
const monitor = this.activeMonitors.get(sessionId);
|
||||
if (!monitor) return;
|
||||
|
||||
monitor.state = 'stopped';
|
||||
monitor.endTime = Date.now();
|
||||
monitor.duration = monitor.endTime - monitor.startTime;
|
||||
|
||||
// Write summary
|
||||
this.writeSummary(sessionId, monitor);
|
||||
|
||||
this.activeMonitors.delete(sessionId);
|
||||
console.log(`[ChatMonitor] Stopped monitoring session ${sessionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write monitoring summary
|
||||
*/
|
||||
writeSummary(sessionId, monitor) {
|
||||
const summaryPath = path.join(this.logsPath, `${sessionId}-summary.json`);
|
||||
fs.writeFileSync(summaryPath, JSON.stringify(monitor, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get monitor status for all sessions
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
activeMonitors: this.activeMonitors.size,
|
||||
sessions: Array.from(this.activeMonitors.values()).map(m => ({
|
||||
sessionId: m.sessionId,
|
||||
state: m.state,
|
||||
eventsCount: m.events.length,
|
||||
duration: Date.now() - m.startTime
|
||||
}))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
const chatMonitor = new ChatMonitorService();
|
||||
|
||||
module.exports = chatMonitor;
|
||||
@@ -6,6 +6,14 @@ const os = require('os');
|
||||
const { SYSTEM_PROMPT } = require('./system-prompt');
|
||||
const { extractAllTags, generateOperationSummary, getDyadWriteTags } = require('./tag-parser');
|
||||
const ResponseProcessor = require('./response-processor');
|
||||
// ============================================================
|
||||
// HYBRID APPROACH: Import global EventBus for SSE integration
|
||||
// ============================================================
|
||||
const eventBus = require('./event-bus');
|
||||
// ============================================================
|
||||
// REAL-TIME MONITORING: Import ChatMonitor
|
||||
// ============================================================
|
||||
const chatMonitor = require('./chat-monitor');
|
||||
|
||||
class ClaudeCodeService extends EventEmitter {
|
||||
constructor(vaultPath) {
|
||||
@@ -101,7 +109,7 @@ class ClaudeCodeService extends EventEmitter {
|
||||
});
|
||||
|
||||
// Emit for real-time updates
|
||||
this.emit('session-output', {
|
||||
eventBus.emit('session-output', {
|
||||
sessionId,
|
||||
type: 'stdout',
|
||||
content: text
|
||||
@@ -118,7 +126,7 @@ class ClaudeCodeService extends EventEmitter {
|
||||
content: text
|
||||
});
|
||||
|
||||
this.emit('session-output', {
|
||||
eventBus.emit('session-output', {
|
||||
sessionId,
|
||||
type: 'stderr',
|
||||
content: text
|
||||
@@ -169,6 +177,12 @@ class ClaudeCodeService extends EventEmitter {
|
||||
|
||||
console.log(`[ClaudeService] Sending command to session ${sessionId}:`, command);
|
||||
|
||||
// ============================================================
|
||||
// REAL-TIME MONITORING: Start monitoring for this session
|
||||
// ============================================================
|
||||
chatMonitor.startSessionMonitor(sessionId);
|
||||
chatMonitor.logEvent(sessionId, 'user_message_sent', { command, timestamp: Date.now() });
|
||||
|
||||
// Track command in context
|
||||
session.context.messages.push({
|
||||
role: 'user',
|
||||
@@ -186,7 +200,7 @@ class ClaudeCodeService extends EventEmitter {
|
||||
|
||||
session.lastActivity = new Date().toISOString();
|
||||
|
||||
this.emit('command-sent', {
|
||||
eventBus.emit('command-sent', {
|
||||
sessionId,
|
||||
command
|
||||
});
|
||||
@@ -195,36 +209,34 @@ class ClaudeCodeService extends EventEmitter {
|
||||
const fullCommand = `${SYSTEM_PROMPT}\n\n${command}`;
|
||||
|
||||
// Spawn claude in -p (print) mode for this command
|
||||
const claude = spawn('claude', ['-p', fullCommand], {
|
||||
// NOTE: --output-format json is REQUIRED for non-interactive mode
|
||||
const claude = spawn('claude', ['-p', fullCommand, '--output-format', 'json'], {
|
||||
cwd: session.workingDir,
|
||||
stdio: ['ignore', 'pipe', 'pipe'], // Explicitly set stdio to get stdout/stderr
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color'
|
||||
}
|
||||
},
|
||||
timeout: 120000 // 2 minute timeout to prevent hanging
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// REAL-TIME MONITORING: Log Claude spawn
|
||||
// ============================================================
|
||||
chatMonitor.logEvent(sessionId, 'claude_spawned', {
|
||||
pid: claude.pid,
|
||||
command: 'claude -p <command> --output-format json',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
let output = '';
|
||||
let stderrOutput = '';
|
||||
let rawStdout = '';
|
||||
|
||||
claude.stdout.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
rawStdout += text;
|
||||
console.log(`[ClaudeService] [${sessionId}] stdout:`, text.substring(0, 100));
|
||||
output += text;
|
||||
|
||||
// Add to output buffer
|
||||
session.outputBuffer.push({
|
||||
type: 'stdout',
|
||||
timestamp: new Date().toISOString(),
|
||||
content: text
|
||||
});
|
||||
|
||||
// Emit for real-time updates
|
||||
this.emit('session-output', {
|
||||
sessionId,
|
||||
type: 'stdout',
|
||||
content: text
|
||||
});
|
||||
});
|
||||
|
||||
claude.stderr.on('data', (data) => {
|
||||
@@ -238,7 +250,7 @@ class ClaudeCodeService extends EventEmitter {
|
||||
content: text
|
||||
});
|
||||
|
||||
this.emit('session-output', {
|
||||
eventBus.emit('session-output', {
|
||||
sessionId,
|
||||
type: 'stderr',
|
||||
content: text
|
||||
@@ -248,6 +260,88 @@ class ClaudeCodeService extends EventEmitter {
|
||||
claude.on('close', (code) => {
|
||||
console.log(`[ClaudeService] [${sessionId}] Command completed with exit code ${code}`);
|
||||
|
||||
// Parse JSON output from Claude CLI
|
||||
// The --output-format json flag returns: { "type": "result", "result": "...", ... }
|
||||
try {
|
||||
const jsonOutput = JSON.parse(rawStdout.trim());
|
||||
|
||||
if (jsonOutput.type === 'result' && jsonOutput.result) {
|
||||
output = jsonOutput.result;
|
||||
|
||||
// ============================================================
|
||||
// REAL-TIME MONITORING: Log JSON parsing success
|
||||
// ============================================================
|
||||
chatMonitor.logEvent(sessionId, 'json_parsed', {
|
||||
resultLength: output.length,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Add to output buffer
|
||||
session.outputBuffer.push({
|
||||
type: 'stdout',
|
||||
timestamp: new Date().toISOString(),
|
||||
content: output
|
||||
});
|
||||
|
||||
// Emit for real-time updates
|
||||
eventBus.emit('session-output', {
|
||||
sessionId,
|
||||
type: 'stdout',
|
||||
content: output
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// REAL-TIME MONITORING: Log SSE emit and AI response
|
||||
// ============================================================
|
||||
chatMonitor.logEvent(sessionId, 'sse_emit', {
|
||||
eventType: 'session-output',
|
||||
contentLength: output.length,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
chatMonitor.logEvent(sessionId, 'ai_response', {
|
||||
content: output.substring(0, 200),
|
||||
fullLength: output.length,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
console.log(`[ClaudeService] [${sessionId}] Parsed JSON output, result length: ${output.length}`);
|
||||
} else if (jsonOutput.type === 'error') {
|
||||
// Handle error responses
|
||||
const errorMsg = jsonOutput.error || jsonOutput.message || 'Unknown error';
|
||||
console.error(`[ClaudeService] [${sessionId}] Claude CLI error:`, errorMsg);
|
||||
|
||||
eventBus.emit('session-output', {
|
||||
sessionId,
|
||||
type: 'stderr',
|
||||
content: `Error: ${errorMsg}`
|
||||
});
|
||||
} else {
|
||||
// Fallback to raw output if JSON structure is unexpected
|
||||
console.warn(`[ClaudeService] [${sessionId}] Unexpected JSON structure:`, jsonOutput);
|
||||
output = rawStdout;
|
||||
}
|
||||
} catch (parseError) {
|
||||
// If parsing fails, treat as raw text output
|
||||
console.warn(`[ClaudeService] [${sessionId}] Failed to parse JSON output, using raw:`, parseError.message);
|
||||
output = rawStdout;
|
||||
|
||||
if (output.trim()) {
|
||||
// Emit raw output as fallback
|
||||
session.outputBuffer.push({
|
||||
type: 'stdout',
|
||||
timestamp: new Date().toISOString(),
|
||||
content: output
|
||||
});
|
||||
|
||||
eventBus.emit('session-output', {
|
||||
sessionId,
|
||||
type: 'stdout',
|
||||
content: output
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add assistant response to context
|
||||
if (output.trim()) {
|
||||
session.context.messages.push({
|
||||
@@ -262,7 +356,7 @@ class ClaudeCodeService extends EventEmitter {
|
||||
if (tags.writes.length > 0 || tags.renames.length > 0 || tags.deletes.length > 0 || tags.dependencies.length > 0) {
|
||||
const operations = generateOperationSummary(tags);
|
||||
|
||||
this.emit('operations-detected', {
|
||||
eventBus.emit('operations-detected', {
|
||||
sessionId,
|
||||
response: output,
|
||||
tags,
|
||||
@@ -272,7 +366,7 @@ class ClaudeCodeService extends EventEmitter {
|
||||
console.log(`[ClaudeService] Detected ${operations.length} operations requiring approval`);
|
||||
}
|
||||
|
||||
this.emit('command-complete', {
|
||||
eventBus.emit('command-complete', {
|
||||
sessionId,
|
||||
exitCode: code,
|
||||
output
|
||||
@@ -284,7 +378,7 @@ class ClaudeCodeService extends EventEmitter {
|
||||
|
||||
claude.on('error', (error) => {
|
||||
console.error(`[ClaudeService] [${sessionId}] Process error:`, error);
|
||||
this.emit('session-error', {
|
||||
eventBus.emit('session-error', {
|
||||
sessionId,
|
||||
error: error.message
|
||||
});
|
||||
@@ -316,7 +410,7 @@ class ClaudeCodeService extends EventEmitter {
|
||||
});
|
||||
|
||||
// Emit approval-request event for WebSocket to handle
|
||||
this.emit('approval-request', {
|
||||
eventBus.emit('approval-request', {
|
||||
sessionId,
|
||||
command,
|
||||
explanation
|
||||
@@ -478,15 +572,17 @@ class ClaudeCodeService extends EventEmitter {
|
||||
|
||||
/**
|
||||
* List all sessions
|
||||
* Sessions in memory are considered active/running even without a process
|
||||
* because processes are spawned on-demand when commands are sent
|
||||
*/
|
||||
listSessions() {
|
||||
return Array.from(this.sessions.values()).map(session => {
|
||||
const metadata = this.calculateSessionMetadata(session);
|
||||
|
||||
// FIX: Only mark as running if process is actually alive
|
||||
const isRunning = session.status === 'running' &&
|
||||
session.process &&
|
||||
!session.process.killed;
|
||||
// FIX: Sessions in memory are considered active
|
||||
// In the new architecture, processes are spawned on-demand
|
||||
// A session is "running" if it exists in memory and hasn't been stopped
|
||||
const isRunning = session.status === 'running';
|
||||
|
||||
return {
|
||||
id: session.id,
|
||||
@@ -777,7 +873,7 @@ class ClaudeCodeService extends EventEmitter {
|
||||
}
|
||||
);
|
||||
|
||||
this.emit('operations-executed', {
|
||||
eventBus.emit('operations-executed', {
|
||||
sessionId,
|
||||
results
|
||||
});
|
||||
@@ -785,7 +881,7 @@ class ClaudeCodeService extends EventEmitter {
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error(`[ClaudeService] Error executing operations:`, error);
|
||||
this.emit('operations-error', {
|
||||
eventBus.emit('operations-error', {
|
||||
sessionId,
|
||||
error: error.message
|
||||
});
|
||||
|
||||
185
services/event-bus.js
Normal file
185
services/event-bus.js
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* EventBus - Central event pub/sub system for session events
|
||||
*
|
||||
* Replaces callback-based event handling with a clean publish/subscribe pattern.
|
||||
* All session events flow through the EventBus, allowing multiple subscribers
|
||||
* to listen to the same events.
|
||||
*/
|
||||
|
||||
const EventEmitter = require('events');
|
||||
|
||||
class EventBus extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.setMaxListeners(0); // Unlimited listeners for scalability
|
||||
|
||||
// Metrics for monitoring
|
||||
this.metrics = {
|
||||
eventsEmitted: 0,
|
||||
eventsByType: new Map(),
|
||||
listenerCounts: new Map()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an event type
|
||||
* @param {string} eventType - Event type (e.g., 'session-output', 'session-error')
|
||||
* @param {string|null} sessionId - Session ID to filter events (null for all sessions)
|
||||
* @param {Function} handler - Event handler function
|
||||
* @returns {Function} Unsubscribe function
|
||||
*/
|
||||
subscribe(eventType, sessionId, handler) {
|
||||
const listenerId = `${eventType}-${sessionId || 'global'}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const wrappedHandler = (data) => {
|
||||
// Filter by session ID if specified
|
||||
if (sessionId !== null && data.sessionId !== sessionId) {
|
||||
console.log(`[EventBus] Filtered event ${eventType}: subscribed=${sessionId}, data.sessionId=${data.sessionId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[EventBus] Calling handler for ${eventType}, session ${sessionId}`);
|
||||
|
||||
try {
|
||||
handler(data);
|
||||
} catch (error) {
|
||||
console.error(`[EventBus] Error in handler for ${eventType}:`, error);
|
||||
console.error(`[EventBus] Handler:`, handler.name || 'anonymous');
|
||||
console.error(`[EventBus] Data:`, data);
|
||||
}
|
||||
};
|
||||
|
||||
this.on(eventType, wrappedHandler);
|
||||
|
||||
// Track listener count for metrics
|
||||
const key = `${eventType}-${sessionId || 'global'}`;
|
||||
this.metrics.listenerCounts.set(key, (this.metrics.listenerCounts.get(key) || 0) + 1);
|
||||
|
||||
console.log(`[EventBus] Subscribed to ${eventType} for session ${sessionId || 'all'}. Total: ${this.metrics.listenerCounts.get(key)}`);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
this.off(eventType, wrappedHandler);
|
||||
const currentCount = this.metrics.listenerCounts.get(key) || 0;
|
||||
this.metrics.listenerCounts.set(key, Math.max(0, currentCount - 1));
|
||||
console.log(`[EventBus] Unsubscribed from ${eventType} for session ${sessionId || 'all'}. Remaining: ${this.metrics.listenerCounts.get(key)}`);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to all subscribers
|
||||
* @param {string} eventType - Event type
|
||||
* @param {Object} data - Event data (must include sessionId for session-scoped events)
|
||||
*/
|
||||
emit(eventType, data = {}) {
|
||||
this.metrics.eventsEmitted++;
|
||||
this.metrics.eventsByType.set(eventType, (this.metrics.eventsByType.get(eventType) || 0) + 1);
|
||||
|
||||
// Add metadata to all events
|
||||
const eventData = {
|
||||
...data,
|
||||
_timestamp: Date.now(),
|
||||
_eventType: eventType
|
||||
};
|
||||
|
||||
// Log important events
|
||||
if (eventType.includes('error') || eventType.includes('expired')) {
|
||||
console.error(`[EventBus] Emitting ${eventType}:`, data);
|
||||
} else {
|
||||
console.log(`[EventBus] Emitting ${eventType} for session ${data.sessionId || 'unknown'}`);
|
||||
}
|
||||
|
||||
super.emit(eventType, eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to all events for a specific session
|
||||
* @param {string} sessionId - Session ID
|
||||
* @param {Function} handler - Handler for all session events
|
||||
* @returns {Function} Unsubscribe function
|
||||
*/
|
||||
subscribeToSession(sessionId, handler) {
|
||||
const eventTypes = [
|
||||
'session-output',
|
||||
'session-error',
|
||||
'session-status',
|
||||
'operations-detected',
|
||||
'operations-executed',
|
||||
'operations-error',
|
||||
'approval-request',
|
||||
'approval-confirmed',
|
||||
'approval-expired',
|
||||
'session-created',
|
||||
'session-deleted',
|
||||
'command-sent',
|
||||
'command-complete',
|
||||
'terminal-created',
|
||||
'terminal-closed'
|
||||
];
|
||||
|
||||
console.log(`[EventBus] Subscribing to all events for session ${sessionId}`);
|
||||
|
||||
const unsubscribers = eventTypes.map(type =>
|
||||
this.subscribe(type, sessionId, handler)
|
||||
);
|
||||
|
||||
// Return combined unsubscribe function
|
||||
return () => {
|
||||
console.log(`[EventBus] Unsubscribing from all events for session ${sessionId}`);
|
||||
unsubscribers.forEach(unsub => unsub());
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current metrics
|
||||
* @returns {Object} Metrics object
|
||||
*/
|
||||
getMetrics() {
|
||||
return {
|
||||
eventsEmitted: this.metrics.eventsEmitted,
|
||||
eventsByType: Object.fromEntries(this.metrics.eventsByType),
|
||||
listenerCounts: Object.fromEntries(this.metrics.listenerCounts),
|
||||
activeListeners: this.listenerCount('session-output') +
|
||||
this.listenerCount('session-error') +
|
||||
this.listenerCount('session-status') +
|
||||
this.listenerCount('operations-detected')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log metrics summary
|
||||
*/
|
||||
logMetrics() {
|
||||
const metrics = this.getMetrics();
|
||||
console.log('[EventBus] Metrics Summary:');
|
||||
console.log(` Total events emitted: ${metrics.eventsEmitted}`);
|
||||
console.log(` Events by type:`, metrics.eventsByType);
|
||||
console.log(` Listener counts:`, metrics.listenerCounts);
|
||||
console.log(` Active listeners: ${metrics.activeListeners}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all listeners and reset metrics (useful for testing)
|
||||
*/
|
||||
clear() {
|
||||
this.removeAllListeners();
|
||||
this.metrics = {
|
||||
eventsEmitted: 0,
|
||||
eventsByType: new Map(),
|
||||
listenerCounts: new Map()
|
||||
};
|
||||
console.log('[EventBus] Cleared all listeners and reset metrics');
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
const eventBus = new EventBus();
|
||||
|
||||
// Log metrics periodically (every 5 minutes in production)
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
setInterval(() => {
|
||||
eventBus.logMetrics();
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
module.exports = eventBus;
|
||||
331
services/sse-manager.js
Normal file
331
services/sse-manager.js
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* SSEManager - Manages Server-Sent Events connections
|
||||
*
|
||||
* Handles SSE connection lifecycle, heartbeat, and event delivery
|
||||
* for real-time streaming of session events to clients.
|
||||
*/
|
||||
|
||||
const eventBus = require('./event-bus');
|
||||
|
||||
class SSEManager {
|
||||
constructor() {
|
||||
// Map: sessionId -> Set of response objects
|
||||
this.connections = new Map();
|
||||
|
||||
// Heartbeat configuration
|
||||
this.heartbeatInterval = 30000; // 30 seconds
|
||||
this.heartbeatTimers = new Map();
|
||||
|
||||
// Connection tracking
|
||||
this.totalConnectionsCreated = 0;
|
||||
this.totalConnectionsClosed = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add SSE connection for a session
|
||||
* @param {string} sessionId - Session ID
|
||||
* @param {Object} res - Express response object
|
||||
* @param {Object} req - Express request object
|
||||
*/
|
||||
addConnection(sessionId, res, req) {
|
||||
// Setup SSE headers
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
// Flush headers to establish connection
|
||||
res.flushHeaders();
|
||||
|
||||
// Add connection to set
|
||||
if (!this.connections.has(sessionId)) {
|
||||
this.connections.set(sessionId, new Set());
|
||||
}
|
||||
const connectionSet = this.connections.get(sessionId);
|
||||
connectionSet.add(res);
|
||||
|
||||
this.totalConnectionsCreated++;
|
||||
|
||||
console.log(`[SSEManager] ✓ Connection added for session ${sessionId}`);
|
||||
console.log(`[SSEManager] Total connections for ${sessionId}: ${connectionSet.size}`);
|
||||
console.log(`[SSEManager] Total active sessions: ${this.connections.size}`);
|
||||
console.log(`[SSEManager] Total connections created: ${this.totalConnectionsCreated}`);
|
||||
|
||||
// Get client info
|
||||
const clientIp = req.ip || req.socket.remoteAddress;
|
||||
const userAgent = req.get('User-Agent') || 'unknown';
|
||||
|
||||
// Send initial connection event
|
||||
this.sendEvent(sessionId, res, {
|
||||
type: 'connected',
|
||||
sessionId,
|
||||
timestamp: Date.now(),
|
||||
message: 'SSE connection established',
|
||||
client: {
|
||||
ip: clientIp,
|
||||
userAgent: userAgent.substring(0, 100) // Truncate very long user agents
|
||||
}
|
||||
});
|
||||
|
||||
// Start heartbeat for this connection
|
||||
this.startHeartbeat(sessionId, res);
|
||||
|
||||
// Subscribe to all session events from EventBus
|
||||
const unsubscribe = eventBus.subscribeToSession(sessionId, (eventData) => {
|
||||
console.log(`[SSEManager] Received event for session ${sessionId}:`, eventData.type, eventData._eventType);
|
||||
this.sendEvent(sessionId, res, eventData);
|
||||
});
|
||||
|
||||
// Handle client disconnect
|
||||
req.on('close', () => {
|
||||
this.removeConnection(sessionId, res, unsubscribe);
|
||||
});
|
||||
|
||||
// Log initial session status
|
||||
eventBus.emit('session-status', {
|
||||
sessionId,
|
||||
status: 'sse-connected',
|
||||
connections: connectionSet.size
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SSE event to a specific connection
|
||||
* @param {string} sessionId - Session ID
|
||||
* @param {Object} res - Response object
|
||||
* @param {Object} data - Event data
|
||||
* @returns {boolean} True if sent successfully
|
||||
*/
|
||||
sendEvent(sessionId, res, data) {
|
||||
// Check if connection is still alive
|
||||
if (res.destroyed || res.writableEnded || res.closed) {
|
||||
console.log(`[SSEManager] Skipping send to ${sessionId} - connection closed`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the EventBus event type (_eventType) for the SSE event name
|
||||
// This ensures frontend receives events like 'session-output' not 'stdout'
|
||||
const eventName = data._eventType || data.type || 'message';
|
||||
const eventData = JSON.stringify(data);
|
||||
|
||||
console.log(`[SSEManager] SENDING to ${sessionId}: event=${eventName}, _eventType=${data._eventType}, type=${data.type}`);
|
||||
|
||||
// SSE format: event: <name>\ndata: <json>\nid: <id>\n\n
|
||||
res.write(`event: ${eventName}\n`);
|
||||
res.write(`data: ${eventData}\n`);
|
||||
res.write(`id: ${Date.now()}\n`);
|
||||
res.write('\n');
|
||||
|
||||
console.log(`[SSEManager] ✓ Sent to ${sessionId}: ${eventName}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[SSEManager] ✗ Error sending event to ${sessionId}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send event to all connections for a session
|
||||
* @param {string} sessionId - Session ID
|
||||
* @param {Object} data - Event data
|
||||
* @returns {number} Number of clients the event was sent to
|
||||
*/
|
||||
broadcastToSession(sessionId, data) {
|
||||
const connectionSet = this.connections.get(sessionId);
|
||||
if (!connectionSet) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let sentCount = 0;
|
||||
const deadConnections = [];
|
||||
|
||||
for (const res of connectionSet) {
|
||||
if (!this.sendEvent(sessionId, res, data)) {
|
||||
deadConnections.push(res);
|
||||
} else {
|
||||
sentCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up dead connections
|
||||
deadConnections.forEach(res => {
|
||||
console.warn(`[SSEManager] Removing dead connection for session ${sessionId}`);
|
||||
connectionSet.delete(res);
|
||||
this.totalConnectionsClosed++;
|
||||
});
|
||||
|
||||
if (deadConnections.length > 0) {
|
||||
console.log(`[SSEManager] Cleaned up ${deadConnections.length} dead connection(s) for ${sessionId}`);
|
||||
}
|
||||
|
||||
return sentCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start heartbeat for a connection
|
||||
* @param {string} sessionId - Session ID
|
||||
* @param {Object} res - Response object
|
||||
*/
|
||||
startHeartbeat(sessionId, res) {
|
||||
const connectionId = `${sessionId}-${res.socket?.remotePort || Math.random()}`;
|
||||
|
||||
const timerId = setInterval(() => {
|
||||
if (res.destroyed || res.writableEnded || res.closed) {
|
||||
clearInterval(timerId);
|
||||
this.heartbeatTimers.delete(connectionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send heartbeat comment (keeps connection alive)
|
||||
try {
|
||||
res.write(': heartbeat\n\n');
|
||||
} catch (error) {
|
||||
console.error(`[SSEManager] Heartbeat failed for ${sessionId}:`, error.message);
|
||||
clearInterval(timerId);
|
||||
this.heartbeatTimers.delete(connectionId);
|
||||
this.removeConnection(sessionId, res, () => {});
|
||||
}
|
||||
}, this.heartbeatInterval);
|
||||
|
||||
this.heartbeatTimers.set(connectionId, timerId);
|
||||
console.log(`[SSEManager] Heartbeat started for ${sessionId} (${this.heartbeatInterval}ms interval)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove SSE connection
|
||||
* @param {string} sessionId - Session ID
|
||||
* @param {Object} res - Response object
|
||||
* @param {Function} unsubscribe - Unsubscribe function from EventBus
|
||||
*/
|
||||
removeConnection(sessionId, res, unsubscribe) {
|
||||
const connectionSet = this.connections.get(sessionId);
|
||||
if (connectionSet) {
|
||||
connectionSet.delete(res);
|
||||
|
||||
if (connectionSet.size === 0) {
|
||||
this.connections.delete(sessionId);
|
||||
console.log(`[SSEManager] No more connections for session ${sessionId}, removing from tracking`);
|
||||
}
|
||||
|
||||
this.totalConnectionsClosed++;
|
||||
}
|
||||
|
||||
console.log(`[SSEManager] ✗ Connection removed for session ${sessionId}`);
|
||||
console.log(`[SSEManager] Remaining connections for ${sessionId}: ${connectionSet?.size || 0}`);
|
||||
|
||||
// Stop heartbeat
|
||||
const connectionId = `${sessionId}-${res.socket?.remotePort || 'unknown'}`;
|
||||
const timerId = this.heartbeatTimers.get(connectionId);
|
||||
if (timerId) {
|
||||
clearInterval(timerId);
|
||||
this.heartbeatTimers.delete(connectionId);
|
||||
console.log(`[SSEManager] Heartbeat stopped for ${sessionId}`);
|
||||
}
|
||||
|
||||
// Unsubscribe from events
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
// Log final status
|
||||
console.log(`[SSEManager] Total connections created: ${this.totalConnectionsCreated}`);
|
||||
console.log(`[SSEManager] Total connections closed: ${this.totalConnectionsClosed}`);
|
||||
console.log(`[SSEManager] Active connections: ${this.totalConnectionsCreated - this.totalConnectionsClosed}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection count for a session
|
||||
* @param {string} sessionId - Session ID
|
||||
* @returns {number} Number of active connections
|
||||
*/
|
||||
getConnectionCount(sessionId) {
|
||||
return this.connections.get(sessionId)?.size || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active session IDs
|
||||
* @returns {Array<string>} Array of session IDs with active connections
|
||||
*/
|
||||
getActiveSessions() {
|
||||
return Array.from(this.connections.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed connection stats
|
||||
* @returns {Object} Connection statistics
|
||||
*/
|
||||
getStats() {
|
||||
const sessionStats = {};
|
||||
for (const [sessionId, connectionSet] of this.connections.entries()) {
|
||||
sessionStats[sessionId] = connectionSet.size;
|
||||
}
|
||||
|
||||
return {
|
||||
totalSessions: this.connections.size,
|
||||
totalConnections: Array.from(this.connections.values())
|
||||
.reduce((sum, set) => sum + set.size, 0),
|
||||
sessions: sessionStats,
|
||||
totalCreated: this.totalConnectionsCreated,
|
||||
totalClosed: this.totalConnectionsClosed,
|
||||
activeHeartbeats: this.heartbeatTimers.size
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all connections (for shutdown)
|
||||
*/
|
||||
cleanup() {
|
||||
console.log('[SSEManager] Cleaning up all connections...');
|
||||
|
||||
let closedCount = 0;
|
||||
|
||||
for (const [sessionId, connectionSet] of this.connections.entries()) {
|
||||
for (const res of connectionSet) {
|
||||
try {
|
||||
res.write('event: shutdown\ndata: {"message":"Server shutting down"}\n\n');
|
||||
res.end();
|
||||
closedCount++;
|
||||
} catch (error) {
|
||||
console.error(`[SSEManager] Error closing connection for ${sessionId}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all heartbeat timers
|
||||
for (const timerId of this.heartbeatTimers.values()) {
|
||||
clearInterval(timerId);
|
||||
}
|
||||
|
||||
this.connections.clear();
|
||||
this.heartbeatTimers.clear();
|
||||
|
||||
console.log(`[SSEManager] Cleanup complete. Closed ${closedCount} connection(s)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log connection stats
|
||||
*/
|
||||
logStats() {
|
||||
const stats = this.getStats();
|
||||
console.log('[SSEManager] Connection Stats:');
|
||||
console.log(` Active sessions: ${stats.totalSessions}`);
|
||||
console.log(` Total connections: ${stats.totalConnections}`);
|
||||
console.log(` Sessions:`, stats.sessions);
|
||||
console.log(` Total created: ${stats.totalCreated}`);
|
||||
console.log(` Total closed: ${stats.totalClosed}`);
|
||||
console.log(` Active heartbeats: ${stats.activeHeartbeats}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
const sseManager = new SSEManager();
|
||||
|
||||
// Log stats periodically (every 5 minutes in production)
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
setInterval(() => {
|
||||
sseManager.logStats();
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
module.exports = sseManager;
|
||||
Reference in New Issue
Block a user