data pipeline refactor and fix
This commit is contained in:
@@ -2,12 +2,14 @@ import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import type { WebSocket } from '@fastify/websocket';
|
||||
import type { Authenticator } from '../auth/authenticator.js';
|
||||
import type { AgentHarness, HarnessFactory } from '../harness/agent-harness.js';
|
||||
import type { HarnessEvent } from '../harness/harness-events.js';
|
||||
import type { InboundMessage } from '../types/messages.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { SessionRegistry, EventSubscriber, Session } from '../events/index.js';
|
||||
import type { OHLCService } from '../services/ohlc-service.js';
|
||||
import type { OHLCService, BarUpdateCallback } from '../services/ohlc-service.js';
|
||||
import type { SymbolIndexService } from '../services/symbol-index-service.js';
|
||||
import type { ContainerManager } from '../k8s/container-manager.js';
|
||||
import type { ConversationService } from '../services/conversation-service.js';
|
||||
import {
|
||||
WorkspaceManager,
|
||||
ContainerSync,
|
||||
@@ -42,6 +44,7 @@ export interface WebSocketHandlerConfig {
|
||||
createHarness: HarnessFactory;
|
||||
ohlcService?: OHLCService; // Optional for historical data support
|
||||
symbolIndexService?: SymbolIndexService; // Optional for symbol search
|
||||
conversationService?: ConversationService; // Optional for history replay on reconnect
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,10 +53,18 @@ export interface WebSocketHandlerConfig {
|
||||
* Handles WebSocket connections for chat and integrates with the event system
|
||||
* for container-to-client notifications.
|
||||
*/
|
||||
interface BarSubscription {
|
||||
ticker: string;
|
||||
periodSeconds: number;
|
||||
callback: BarUpdateCallback;
|
||||
}
|
||||
|
||||
export class WebSocketHandler {
|
||||
private config: WebSocketHandlerConfig;
|
||||
private harnesses = new Map<string, AgentHarness>();
|
||||
private workspaces = new Map<string, WorkspaceManager>();
|
||||
/** Per-session realtime bar subscriptions for cleanup on disconnect */
|
||||
private barSubscriptions = new Map<string, BarSubscription[]>();
|
||||
|
||||
constructor(config: WebSocketHandlerConfig) {
|
||||
this.config = config;
|
||||
@@ -106,17 +117,22 @@ export class WebSocketHandler {
|
||||
|
||||
// If container is spinning up, wait for it to be ready before continuing
|
||||
if (isSpinningUp) {
|
||||
sendStatus(socket, 'spinning_up', 'Your workspace is starting up, please wait...');
|
||||
sendStatus(socket, 'spinning_up', 'Your personal agent is starting up, please wait...');
|
||||
|
||||
const startupPingInterval = setInterval(() => {
|
||||
if (socket.readyState === 1) socket.ping();
|
||||
}, 10000);
|
||||
|
||||
const ready = await this.config.containerManager.waitForContainerReady(authContext.userId, 120000);
|
||||
clearInterval(startupPingInterval);
|
||||
if (!ready) {
|
||||
logger.warn({ userId: authContext.userId }, 'Container failed to become ready within timeout');
|
||||
socket.send(JSON.stringify({ type: 'error', message: 'Workspace failed to start. Please try again later.' }));
|
||||
logger.warn({ userId: authContext.userId }, 'Sandbox failed to become ready within timeout');
|
||||
socket.send(JSON.stringify({ type: 'error', message: 'Agent workspace failed to start. Please try again later.' }));
|
||||
socket.close(1011, 'Container startup timeout');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info({ userId: authContext.userId }, 'Container is ready, proceeding with session setup');
|
||||
logger.info({ userId: authContext.userId }, 'Sandbox is ready, proceeding with session setup');
|
||||
}
|
||||
|
||||
sendStatus(socket, 'initializing', 'Starting your workspace...');
|
||||
@@ -241,6 +257,17 @@ export class WebSocketHandler {
|
||||
})
|
||||
);
|
||||
|
||||
// Replay conversation history so the UI pre-populates on reconnect
|
||||
if (this.config.conversationService) {
|
||||
const history = await this.config.conversationService.getHistory(
|
||||
authContext.userId,
|
||||
authContext.sessionId
|
||||
);
|
||||
if (history.length > 0) {
|
||||
socket.send(JSON.stringify({ type: 'conversation_history', messages: history }));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle messages
|
||||
socket.on('message', async (data: Buffer) => {
|
||||
try {
|
||||
@@ -266,15 +293,45 @@ export class WebSocketHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Chunks are streamed via channelAdapter.sendChunk() during handleMessage
|
||||
try {
|
||||
// Acknowledge receipt immediately so the client can show the seen indicator
|
||||
socket.send(JSON.stringify({ type: 'agent_chunk', content: '', done: false }));
|
||||
|
||||
logger.info('Calling harness.handleMessage');
|
||||
await harness.handleMessage(inboundMessage);
|
||||
logger.info('Streaming harness response');
|
||||
let fatalError = false;
|
||||
for await (const event of harness.streamMessage(inboundMessage)) {
|
||||
const e = event as HarnessEvent;
|
||||
switch (e.type) {
|
||||
case 'chunk':
|
||||
socket.send(JSON.stringify({ type: 'agent_chunk', content: e.content, done: false }));
|
||||
break;
|
||||
case 'tool_call':
|
||||
socket.send(JSON.stringify({ type: 'agent_tool_call', toolName: e.toolName, label: e.label }));
|
||||
break;
|
||||
case 'subagent_tool_call':
|
||||
socket.send(JSON.stringify({ type: 'subagent_tool_call', agentName: e.agentName, toolName: e.toolName, label: e.label }));
|
||||
break;
|
||||
case 'subagent_chunk':
|
||||
socket.send(JSON.stringify({ type: 'subagent_chunk', agentName: e.agentName, content: e.content }));
|
||||
break;
|
||||
case 'image':
|
||||
socket.send(JSON.stringify({ type: 'image', data: e.data, mimeType: e.mimeType, caption: e.caption }));
|
||||
break;
|
||||
case 'error':
|
||||
socket.send(JSON.stringify({ type: 'text', text: `An unrecoverable error occurred in the ${e.source}.` }));
|
||||
if (e.fatal) fatalError = true;
|
||||
break;
|
||||
case 'done':
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Send done marker after all chunks have been streamed
|
||||
if (fatalError) {
|
||||
socket.close(1011, 'Fatal error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send done marker after all events have been streamed
|
||||
logger.debug('Sending done marker to client');
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
@@ -332,6 +389,17 @@ export class WebSocketHandler {
|
||||
await this.config.eventSubscriber.onSessionDisconnect(removedSession);
|
||||
}
|
||||
|
||||
// Cleanup realtime bar subscriptions
|
||||
const sessionId = authContext.sessionId;
|
||||
const subs = this.barSubscriptions.get(sessionId);
|
||||
if (subs && this.config.ohlcService) {
|
||||
for (const { ticker, periodSeconds, callback } of subs) {
|
||||
this.config.ohlcService.unsubscribeFromTicker(ticker, periodSeconds, callback);
|
||||
}
|
||||
this.barSubscriptions.delete(sessionId);
|
||||
logger.info({ sessionId, count: subs.length }, 'Cleaned up realtime bar subscriptions');
|
||||
}
|
||||
|
||||
// Cleanup workspace
|
||||
await workspace!.shutdown();
|
||||
this.workspaces.delete(authContext.sessionId);
|
||||
@@ -356,6 +424,7 @@ export class WebSocketHandler {
|
||||
}, 30000);
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to initialize session');
|
||||
socket.send(JSON.stringify({ type: 'text', text: 'An unrecoverable error occurred in the agent harness.' }));
|
||||
socket.close(1011, 'Internal server error');
|
||||
if (workspace) {
|
||||
await workspace.shutdown();
|
||||
@@ -527,19 +596,92 @@ export class WebSocketHandler {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'subscribe_bars':
|
||||
case 'unsubscribe_bars':
|
||||
// TODO: Implement real-time subscriptions
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: `${payload.type}_response`,
|
||||
case 'subscribe_bars': {
|
||||
if (!ohlcService || !authContext) {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'subscribe_bars_response',
|
||||
request_id: requestId,
|
||||
subscription_id: payload.subscription_id,
|
||||
success: false,
|
||||
message: 'Real-time subscriptions not yet implemented',
|
||||
})
|
||||
);
|
||||
message: 'Realtime service not available',
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
const subTicker: string = payload.symbol;
|
||||
const subPeriod: number = payload.period_seconds ?? payload.resolution ?? 60;
|
||||
const sessionId = authContext.sessionId;
|
||||
|
||||
// Create a per-subscription callback that forwards bars to this socket
|
||||
const barCallback: BarUpdateCallback = (bar) => {
|
||||
if (socket.readyState !== 1 /* OPEN */) return;
|
||||
socket.send(JSON.stringify({
|
||||
type: 'bar_update',
|
||||
subscription_id: payload.subscription_id,
|
||||
ticker: bar.ticker,
|
||||
period_seconds: bar.periodSeconds,
|
||||
bar: {
|
||||
// Convert nanoseconds → seconds for client compatibility
|
||||
time: Number(bar.timestamp / 1_000_000_000n),
|
||||
open: bar.open,
|
||||
high: bar.high,
|
||||
low: bar.low,
|
||||
close: bar.close,
|
||||
volume: bar.volume,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
ohlcService.subscribeToTicker(subTicker, subPeriod, barCallback);
|
||||
|
||||
// Track for cleanup on disconnect
|
||||
if (!this.barSubscriptions.has(sessionId)) {
|
||||
this.barSubscriptions.set(sessionId, []);
|
||||
}
|
||||
this.barSubscriptions.get(sessionId)!.push({
|
||||
ticker: subTicker,
|
||||
periodSeconds: subPeriod,
|
||||
callback: barCallback,
|
||||
});
|
||||
|
||||
logger.info({ sessionId, ticker: subTicker, period: subPeriod }, 'Subscribed to realtime bars');
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
type: 'subscribe_bars_response',
|
||||
request_id: requestId,
|
||||
subscription_id: payload.subscription_id,
|
||||
success: true,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'unsubscribe_bars': {
|
||||
if (!ohlcService || !authContext) break;
|
||||
|
||||
const unsubTicker: string = payload.symbol;
|
||||
const unsubPeriod: number = payload.period_seconds ?? payload.resolution ?? 60;
|
||||
const sessionId = authContext.sessionId;
|
||||
|
||||
const subs = this.barSubscriptions.get(sessionId);
|
||||
if (subs) {
|
||||
const idx = subs.findIndex(
|
||||
s => s.ticker === unsubTicker && s.periodSeconds === unsubPeriod
|
||||
);
|
||||
if (idx >= 0) {
|
||||
const [removed] = subs.splice(idx, 1);
|
||||
ohlcService.unsubscribeFromTicker(unsubTicker, unsubPeriod, removed.callback);
|
||||
logger.info({ sessionId, ticker: unsubTicker, period: unsubPeriod }, 'Unsubscribed from realtime bars');
|
||||
}
|
||||
}
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
type: 'unsubscribe_bars_response',
|
||||
request_id: requestId,
|
||||
subscription_id: payload.subscription_id,
|
||||
success: true,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'evaluate_indicator': {
|
||||
// Direct MCP call — bypasses the agent/LLM for performance
|
||||
|
||||
Reference in New Issue
Block a user