mirror of
https://github.com/thedotmack/claude-mem
synced 2026-04-25 17:15:04 +02:00
feat: Implement Worker Service v2 with improved architecture
- Complete rewrite of the Worker Service following object-oriented principles. - Introduced a single long-lived database connection to reduce overhead. - Implemented event-driven queues to eliminate polling. - Added DRY utilities for pagination and settings management. - Reduced code size significantly from 1173 lines to approximately 600-700 lines. - Created various composed services including DatabaseManager, SessionManager, and SDKAgent. - Enhanced SSE broadcasting capabilities for real-time client updates. - Established a structured approach for session lifecycle management and event handling. - Introduced type safety with shared TypeScript interfaces for better maintainability.
This commit is contained in:
1174
docs/worker-service-architecture.md
Normal file
1174
docs/worker-service-architecture.md
Normal file
File diff suppressed because it is too large
Load Diff
1069
docs/worker-service-rewrite-outline.md
Normal file
1069
docs/worker-service-rewrite-outline.md
Normal file
File diff suppressed because it is too large
Load Diff
19
package-lock.json
generated
19
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "5.1.0",
|
||||
"version": "5.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-mem",
|
||||
"version": "5.1.0",
|
||||
"version": "5.1.2",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.27",
|
||||
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.3.5",
|
||||
@@ -1396,6 +1397,16 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz",
|
||||
@@ -1473,6 +1484,7 @@
|
||||
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
@@ -2326,6 +2338,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
@@ -3838,6 +3851,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -4839,6 +4853,7 @@
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.3.5",
|
||||
|
||||
487
src/services/worker-service-v2.ts
Normal file
487
src/services/worker-service-v2.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
/**
|
||||
* Worker Service v2: Clean Object-Oriented Architecture
|
||||
*
|
||||
* This is a complete rewrite following the architecture document.
|
||||
* Key improvements:
|
||||
* - Single database connection (no open/close churn)
|
||||
* - Event-driven queues (zero polling)
|
||||
* - DRY utilities for pagination and settings
|
||||
* - Clean separation of concerns
|
||||
* - ~600-700 lines (down from 1173)
|
||||
*/
|
||||
|
||||
import express, { Request, Response } from 'express';
|
||||
import cors from 'cors';
|
||||
import http from 'http';
|
||||
import path from 'path';
|
||||
import { readFileSync } from 'fs';
|
||||
import { getPackageRoot } from '../shared/paths.js';
|
||||
import { getWorkerPort } from '../shared/worker-utils.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
// Import composed services
|
||||
import { DatabaseManager } from './worker/DatabaseManager.js';
|
||||
import { SessionManager } from './worker/SessionManager.js';
|
||||
import { SSEBroadcaster } from './worker/SSEBroadcaster.js';
|
||||
import { SDKAgent } from './worker/SDKAgent.js';
|
||||
import { PaginationHelper } from './worker/PaginationHelper.js';
|
||||
import { SettingsManager } from './worker/SettingsManager.js';
|
||||
|
||||
export class WorkerService {
|
||||
private app: express.Application;
|
||||
private server: http.Server | null = null;
|
||||
|
||||
// Composed services
|
||||
private dbManager: DatabaseManager;
|
||||
private sessionManager: SessionManager;
|
||||
private sseBroadcaster: SSEBroadcaster;
|
||||
private sdkAgent: SDKAgent;
|
||||
private paginationHelper: PaginationHelper;
|
||||
private settingsManager: SettingsManager;
|
||||
|
||||
constructor() {
|
||||
this.app = express();
|
||||
|
||||
// Initialize services (dependency injection)
|
||||
this.dbManager = new DatabaseManager();
|
||||
this.sessionManager = new SessionManager(this.dbManager);
|
||||
this.sseBroadcaster = new SSEBroadcaster();
|
||||
this.sdkAgent = new SDKAgent(this.dbManager, this.sessionManager);
|
||||
this.paginationHelper = new PaginationHelper(this.dbManager);
|
||||
this.settingsManager = new SettingsManager(this.dbManager);
|
||||
|
||||
this.setupMiddleware();
|
||||
this.setupRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Express middleware
|
||||
*/
|
||||
private setupMiddleware(): void {
|
||||
this.app.use(express.json({ limit: '50mb' }));
|
||||
this.app.use(cors());
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup HTTP routes
|
||||
*/
|
||||
private setupRoutes(): void {
|
||||
// Health & Viewer
|
||||
this.app.get('/health', this.handleHealth.bind(this));
|
||||
this.app.get('/', this.handleViewerUI.bind(this));
|
||||
this.app.get('/stream', this.handleSSEStream.bind(this));
|
||||
|
||||
// Session endpoints
|
||||
this.app.post('/sessions/:sessionDbId/init', this.handleSessionInit.bind(this));
|
||||
this.app.post('/sessions/:sessionDbId/observations', this.handleObservations.bind(this));
|
||||
this.app.post('/sessions/:sessionDbId/summarize', this.handleSummarize.bind(this));
|
||||
this.app.get('/sessions/:sessionDbId/status', this.handleSessionStatus.bind(this));
|
||||
this.app.delete('/sessions/:sessionDbId', this.handleSessionDelete.bind(this));
|
||||
this.app.post('/sessions/:sessionDbId/complete', this.handleSessionComplete.bind(this));
|
||||
|
||||
// Data retrieval
|
||||
this.app.get('/api/observations', this.handleGetObservations.bind(this));
|
||||
this.app.get('/api/summaries', this.handleGetSummaries.bind(this));
|
||||
this.app.get('/api/prompts', this.handleGetPrompts.bind(this));
|
||||
this.app.get('/api/stats', this.handleGetStats.bind(this));
|
||||
|
||||
// Settings
|
||||
this.app.get('/api/settings', this.handleGetSettings.bind(this));
|
||||
this.app.post('/api/settings', this.handleUpdateSettings.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the worker service
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
// Initialize database (once, stays open)
|
||||
await this.dbManager.initialize();
|
||||
|
||||
// Cleanup orphaned sessions from previous runs
|
||||
const cleaned = this.dbManager.cleanupOrphanedSessions();
|
||||
if (cleaned > 0) {
|
||||
logger.info('SYSTEM', `Cleaned ${cleaned} orphaned sessions`);
|
||||
}
|
||||
|
||||
// Start HTTP server
|
||||
const port = getWorkerPort();
|
||||
this.server = await new Promise<http.Server>((resolve, reject) => {
|
||||
const srv = this.app.listen(port, () => resolve(srv));
|
||||
srv.on('error', reject);
|
||||
});
|
||||
|
||||
logger.info('SYSTEM', 'Worker started', { port, pid: process.pid });
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the worker service
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
// Shutdown all active sessions
|
||||
await this.sessionManager.shutdownAll();
|
||||
|
||||
// Close HTTP server
|
||||
if (this.server) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server!.close(err => err ? reject(err) : resolve());
|
||||
});
|
||||
}
|
||||
|
||||
// Close database connection
|
||||
await this.dbManager.close();
|
||||
|
||||
logger.info('SYSTEM', 'Worker shutdown complete');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Route Handlers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Health check endpoint
|
||||
*/
|
||||
private handleHealth(req: Request, res: Response): void {
|
||||
res.json({ status: 'ok', timestamp: Date.now() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve viewer UI
|
||||
*/
|
||||
private handleViewerUI(req: Request, res: Response): void {
|
||||
try {
|
||||
const packageRoot = getPackageRoot();
|
||||
const viewerPath = path.join(packageRoot, 'plugin', 'ui', 'viewer.html');
|
||||
const html = readFileSync(viewerPath, 'utf-8');
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.send(html);
|
||||
} catch (error) {
|
||||
logger.failure('WORKER', 'Viewer UI error', {}, error as Error);
|
||||
res.status(500).json({ error: 'Failed to load viewer UI' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE stream endpoint
|
||||
*/
|
||||
private handleSSEStream(req: Request, res: Response): void {
|
||||
// Setup SSE headers
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
// Add client to broadcaster
|
||||
this.sseBroadcaster.addClient(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a new session
|
||||
*/
|
||||
private handleSessionInit(req: Request, res: Response): void {
|
||||
try {
|
||||
const sessionDbId = parseInt(req.params.sessionDbId, 10);
|
||||
const session = this.sessionManager.initializeSession(sessionDbId);
|
||||
|
||||
// Start SDK agent in background
|
||||
this.sdkAgent.startSession(session).catch(err => {
|
||||
logger.failure('WORKER', 'SDK agent error', { sessionId: sessionDbId }, err);
|
||||
});
|
||||
|
||||
// Broadcast SSE event
|
||||
this.sseBroadcaster.broadcast({
|
||||
type: 'session_started',
|
||||
sessionDbId,
|
||||
project: session.project
|
||||
});
|
||||
|
||||
res.json({ status: 'initialized', sessionDbId, port: getWorkerPort() });
|
||||
} catch (error) {
|
||||
logger.failure('WORKER', 'Session init failed', {}, error as Error);
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue observations for processing
|
||||
*/
|
||||
private handleObservations(req: Request, res: Response): void {
|
||||
try {
|
||||
const sessionDbId = parseInt(req.params.sessionDbId, 10);
|
||||
const { tool_name, tool_input, tool_output, prompt_number } = req.body;
|
||||
|
||||
this.sessionManager.queueObservation(sessionDbId, {
|
||||
tool_name,
|
||||
tool_input,
|
||||
tool_output,
|
||||
prompt_number
|
||||
});
|
||||
|
||||
// Broadcast SSE event
|
||||
this.sseBroadcaster.broadcast({
|
||||
type: 'observation_queued',
|
||||
sessionDbId
|
||||
});
|
||||
|
||||
res.json({ status: 'queued' });
|
||||
} catch (error) {
|
||||
logger.failure('WORKER', 'Observation queuing failed', {}, error as Error);
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue summarize request
|
||||
*/
|
||||
private handleSummarize(req: Request, res: Response): void {
|
||||
try {
|
||||
const sessionDbId = parseInt(req.params.sessionDbId, 10);
|
||||
this.sessionManager.queueSummarize(sessionDbId);
|
||||
|
||||
res.json({ status: 'queued' });
|
||||
} catch (error) {
|
||||
logger.failure('WORKER', 'Summarize queuing failed', {}, error as Error);
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session status
|
||||
*/
|
||||
private handleSessionStatus(req: Request, res: Response): void {
|
||||
try {
|
||||
const sessionDbId = parseInt(req.params.sessionDbId, 10);
|
||||
const session = this.sessionManager.getSession(sessionDbId);
|
||||
|
||||
if (!session) {
|
||||
res.json({ status: 'not_found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: 'active',
|
||||
sessionDbId,
|
||||
project: session.project,
|
||||
queueLength: session.pendingMessages.length,
|
||||
uptime: Date.now() - session.startTime
|
||||
});
|
||||
} catch (error) {
|
||||
logger.failure('WORKER', 'Session status failed', {}, error as Error);
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session
|
||||
*/
|
||||
private async handleSessionDelete(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const sessionDbId = parseInt(req.params.sessionDbId, 10);
|
||||
await this.sessionManager.deleteSession(sessionDbId);
|
||||
|
||||
// Mark session complete in database
|
||||
this.dbManager.markSessionComplete(sessionDbId);
|
||||
|
||||
// Broadcast SSE event
|
||||
this.sseBroadcaster.broadcast({
|
||||
type: 'session_completed',
|
||||
sessionDbId
|
||||
});
|
||||
|
||||
res.json({ status: 'deleted' });
|
||||
} catch (error) {
|
||||
logger.failure('WORKER', 'Session delete failed', {}, error as Error);
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a session (backward compatibility for cleanup-hook)
|
||||
* cleanup-hook expects POST /sessions/:sessionDbId/complete instead of DELETE
|
||||
*/
|
||||
private async handleSessionComplete(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const sessionDbId = parseInt(req.params.sessionDbId, 10);
|
||||
if (isNaN(sessionDbId)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid session ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sessionManager.deleteSession(sessionDbId);
|
||||
|
||||
// Mark session complete in database
|
||||
this.dbManager.markSessionComplete(sessionDbId);
|
||||
|
||||
// Broadcast SSE event
|
||||
this.sseBroadcaster.broadcast({
|
||||
type: 'session_completed',
|
||||
timestamp: Date.now(),
|
||||
sessionDbId
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.failure('WORKER', 'Session complete failed', {}, error as Error);
|
||||
res.status(500).json({ success: false, error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated observations
|
||||
*/
|
||||
private handleGetObservations(req: Request, res: Response): void {
|
||||
try {
|
||||
const { offset, limit, project } = parsePaginationParams(req);
|
||||
const result = this.paginationHelper.getObservations(offset, limit, project);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.failure('WORKER', 'Get observations failed', {}, error as Error);
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated summaries
|
||||
*/
|
||||
private handleGetSummaries(req: Request, res: Response): void {
|
||||
try {
|
||||
const { offset, limit, project } = parsePaginationParams(req);
|
||||
const result = this.paginationHelper.getSummaries(offset, limit, project);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.failure('WORKER', 'Get summaries failed', {}, error as Error);
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated user prompts
|
||||
*/
|
||||
private handleGetPrompts(req: Request, res: Response): void {
|
||||
try {
|
||||
const { offset, limit, project } = parsePaginationParams(req);
|
||||
const result = this.paginationHelper.getPrompts(offset, limit, project);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.failure('WORKER', 'Get prompts failed', {}, error as Error);
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database statistics
|
||||
*/
|
||||
private handleGetStats(req: Request, res: Response): void {
|
||||
try {
|
||||
const db = this.dbManager.getSessionStore().db;
|
||||
|
||||
// Get total counts
|
||||
const totalObservations = db.prepare('SELECT COUNT(*) as count FROM observations').get() as { count: number };
|
||||
const totalSessions = db.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number };
|
||||
const totalPrompts = db.prepare('SELECT COUNT(*) as count FROM user_prompts').get() as { count: number };
|
||||
const totalSummaries = db.prepare('SELECT COUNT(*) as count FROM summaries').get() as { count: number };
|
||||
|
||||
// Get project counts
|
||||
const projectCounts: Record<string, any> = {};
|
||||
|
||||
const projects = db.prepare('SELECT DISTINCT project FROM observations').all() as Array<{ project: string }>;
|
||||
|
||||
for (const { project } of projects) {
|
||||
const obsCount = db.prepare('SELECT COUNT(*) as count FROM observations WHERE project = ?').get(project) as { count: number };
|
||||
const sessCount = db.prepare('SELECT COUNT(*) as count FROM sessions WHERE project = ?').get(project) as { count: number };
|
||||
const promptCount = db.prepare('SELECT COUNT(*) as count FROM user_prompts WHERE project = ?').get(project) as { count: number };
|
||||
const summCount = db.prepare('SELECT COUNT(*) as count FROM summaries WHERE project = ?').get(project) as { count: number };
|
||||
|
||||
projectCounts[project] = {
|
||||
observations: obsCount.count,
|
||||
sessions: sessCount.count,
|
||||
prompts: promptCount.count,
|
||||
summaries: summCount.count
|
||||
};
|
||||
}
|
||||
|
||||
res.json({
|
||||
totalObservations: totalObservations.count,
|
||||
totalSessions: totalSessions.count,
|
||||
totalPrompts: totalPrompts.count,
|
||||
totalSummaries: totalSummaries.count,
|
||||
projectCounts
|
||||
});
|
||||
} catch (error) {
|
||||
logger.failure('WORKER', 'Get stats failed', {}, error as Error);
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get viewer settings
|
||||
*/
|
||||
private handleGetSettings(req: Request, res: Response): void {
|
||||
try {
|
||||
const settings = this.settingsManager.getSettings();
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
logger.failure('WORKER', 'Get settings failed', {}, error as Error);
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update viewer settings
|
||||
*/
|
||||
private handleUpdateSettings(req: Request, res: Response): void {
|
||||
try {
|
||||
const updates = req.body;
|
||||
const settings = this.settingsManager.updateSettings(updates);
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
logger.failure('WORKER', 'Update settings failed', {}, error as Error);
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse pagination parameters from request
|
||||
*/
|
||||
function parsePaginationParams(req: Request): { offset: number; limit: number; project?: string } {
|
||||
const offset = parseInt(req.query.offset as string, 10) || 0;
|
||||
const limit = Math.min(parseInt(req.query.limit as string, 10) || 20, 100); // Max 100
|
||||
const project = req.query.project as string | undefined;
|
||||
|
||||
return { offset, limit, project };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Entry Point
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Start the worker service (if running as main module)
|
||||
*/
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const worker = new WorkerService();
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
logger.info('SYSTEM', 'Received SIGTERM, shutting down gracefully');
|
||||
await worker.shutdown();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
logger.info('SYSTEM', 'Received SIGINT, shutting down gracefully');
|
||||
await worker.shutdown();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Start the worker
|
||||
worker.start().catch(error => {
|
||||
logger.failure('SYSTEM', 'Worker startup failed', {}, error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export default WorkerService;
|
||||
174
src/services/worker-types.ts
Normal file
174
src/services/worker-types.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Shared types for Worker Service architecture
|
||||
*/
|
||||
|
||||
import type { Response } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// Active Session Types
|
||||
// ============================================================================
|
||||
|
||||
export interface ActiveSession {
|
||||
sessionDbId: number;
|
||||
claudeSessionId: string;
|
||||
sdkSessionId: string | null;
|
||||
project: string;
|
||||
userPrompt: string;
|
||||
pendingMessages: PendingMessage[];
|
||||
abortController: AbortController;
|
||||
generatorPromise: Promise<void> | null;
|
||||
lastPromptNumber: number;
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
export interface PendingMessage {
|
||||
type: 'observation' | 'summarize';
|
||||
tool_name?: string;
|
||||
tool_input?: any;
|
||||
tool_output?: any;
|
||||
prompt_number?: number;
|
||||
}
|
||||
|
||||
export interface ObservationData {
|
||||
tool_name: string;
|
||||
tool_input: any;
|
||||
tool_output: any;
|
||||
prompt_number: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSE Types
|
||||
// ============================================================================
|
||||
|
||||
export interface SSEEvent {
|
||||
type: string;
|
||||
timestamp?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type SSEClient = Response;
|
||||
|
||||
// ============================================================================
|
||||
// Pagination Types
|
||||
// ============================================================================
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[];
|
||||
hasMore: boolean;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
offset: number;
|
||||
limit: number;
|
||||
project?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Settings Types
|
||||
// ============================================================================
|
||||
|
||||
export interface ViewerSettings {
|
||||
sidebarOpen: boolean;
|
||||
selectedProject: string | null;
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Database Record Types
|
||||
// ============================================================================
|
||||
|
||||
export interface Observation {
|
||||
id: number;
|
||||
session_db_id: number;
|
||||
claude_session_id: string;
|
||||
project: string;
|
||||
type: string;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
text: string;
|
||||
concepts: string | null;
|
||||
files: string | null;
|
||||
prompt_number: number;
|
||||
created_at: string;
|
||||
created_at_epoch: number;
|
||||
}
|
||||
|
||||
export interface Summary {
|
||||
id: number;
|
||||
session_db_id: number;
|
||||
claude_session_id: string;
|
||||
project: string;
|
||||
request: string | null;
|
||||
completion: string | null;
|
||||
summary: string;
|
||||
learnings: string | null;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
created_at_epoch: number;
|
||||
}
|
||||
|
||||
export interface UserPrompt {
|
||||
id: number;
|
||||
session_db_id: number;
|
||||
claude_session_id: string;
|
||||
project: string;
|
||||
prompt: string;
|
||||
created_at: string;
|
||||
created_at_epoch: number;
|
||||
}
|
||||
|
||||
export interface DBSession {
|
||||
id: number;
|
||||
claude_session_id: string;
|
||||
project: string;
|
||||
user_prompt: string;
|
||||
sdk_session_id: string | null;
|
||||
status: 'active' | 'completed' | 'failed';
|
||||
started_at: string;
|
||||
started_at_epoch: number;
|
||||
completed_at: string | null;
|
||||
completed_at_epoch: number | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SDK Types
|
||||
// ============================================================================
|
||||
|
||||
// Re-export the actual SDK type to ensure compatibility
|
||||
export type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
export interface ParsedObservation {
|
||||
type: string;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
text: string;
|
||||
concepts: string[];
|
||||
files: string[];
|
||||
}
|
||||
|
||||
export interface ParsedSummary {
|
||||
request: string | null;
|
||||
completion: string | null;
|
||||
summary: string;
|
||||
learnings: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Types
|
||||
// ============================================================================
|
||||
|
||||
export interface DatabaseStats {
|
||||
totalObservations: number;
|
||||
totalSessions: number;
|
||||
totalPrompts: number;
|
||||
totalSummaries: number;
|
||||
projectCounts: Record<string, {
|
||||
observations: number;
|
||||
sessions: number;
|
||||
prompts: number;
|
||||
summaries: number;
|
||||
}>;
|
||||
}
|
||||
115
src/services/worker/DatabaseManager.ts
Normal file
115
src/services/worker/DatabaseManager.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* DatabaseManager: Single long-lived database connection
|
||||
*
|
||||
* Responsibility:
|
||||
* - Manage single database connection for worker lifetime
|
||||
* - Provide centralized access to SessionStore and SessionSearch
|
||||
* - High-level database operations
|
||||
* - ChromaSync integration
|
||||
*/
|
||||
|
||||
import { SessionStore } from '../sqlite/SessionStore.js';
|
||||
import { SessionSearch } from '../sqlite/SessionSearch.js';
|
||||
import { ChromaSync } from '../sync/ChromaSync.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import type { DBSession } from '../worker-types.js';
|
||||
|
||||
export class DatabaseManager {
|
||||
private sessionStore: SessionStore | null = null;
|
||||
private sessionSearch: SessionSearch | null = null;
|
||||
private chromaSync: ChromaSync | null = null;
|
||||
|
||||
/**
|
||||
* Initialize database connection (once, stays open)
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
// Open database connection (ONCE)
|
||||
this.sessionStore = new SessionStore();
|
||||
this.sessionSearch = new SessionSearch();
|
||||
|
||||
// Initialize ChromaSync
|
||||
this.chromaSync = new ChromaSync('claude-mem');
|
||||
|
||||
// Start background backfill (fire-and-forget)
|
||||
this.chromaSync.ensureBackfilled().catch(() => {});
|
||||
|
||||
logger.info('DB', 'Database initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Close database connection
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (this.sessionStore) {
|
||||
this.sessionStore.close();
|
||||
this.sessionStore = null;
|
||||
}
|
||||
if (this.sessionSearch) {
|
||||
this.sessionSearch.close();
|
||||
this.sessionSearch = null;
|
||||
}
|
||||
logger.info('DB', 'Database closed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SessionStore instance (throws if not initialized)
|
||||
*/
|
||||
getSessionStore(): SessionStore {
|
||||
if (!this.sessionStore) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
return this.sessionStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SessionSearch instance (throws if not initialized)
|
||||
*/
|
||||
getSessionSearch(): SessionSearch {
|
||||
if (!this.sessionSearch) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
return this.sessionSearch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ChromaSync instance (throws if not initialized)
|
||||
*/
|
||||
getChromaSync(): ChromaSync {
|
||||
if (!this.chromaSync) {
|
||||
throw new Error('ChromaSync not initialized');
|
||||
}
|
||||
return this.chromaSync;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup orphaned sessions from previous runs
|
||||
* @returns Number of sessions cleaned
|
||||
*/
|
||||
cleanupOrphanedSessions(): number {
|
||||
return this.getSessionStore().cleanupOrphanedSessions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session by ID (throws if not found)
|
||||
*/
|
||||
getSessionById(sessionDbId: number): {
|
||||
id: number;
|
||||
claude_session_id: string;
|
||||
sdk_session_id: string | null;
|
||||
project: string;
|
||||
user_prompt: string;
|
||||
} {
|
||||
const session = this.getSessionStore().getSessionById(sessionDbId);
|
||||
if (!session) {
|
||||
throw new Error(`Session ${sessionDbId} not found`);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark session as completed
|
||||
*/
|
||||
markSessionComplete(sessionDbId: number): void {
|
||||
this.getSessionStore().markSessionCompleted(sessionDbId);
|
||||
}
|
||||
}
|
||||
92
src/services/worker/PaginationHelper.ts
Normal file
92
src/services/worker/PaginationHelper.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* PaginationHelper: DRY pagination utility
|
||||
*
|
||||
* Responsibility:
|
||||
* - DRY helper for paginated queries
|
||||
* - Eliminates copy-paste across observations/summaries/prompts endpoints
|
||||
* - Efficient LIMIT+1 trick to avoid COUNT(*) query
|
||||
*/
|
||||
|
||||
import { DatabaseManager } from './DatabaseManager.js';
|
||||
import type { PaginatedResult, Observation, Summary, UserPrompt } from '../worker-types.js';
|
||||
|
||||
export class PaginationHelper {
|
||||
private dbManager: DatabaseManager;
|
||||
|
||||
constructor(dbManager: DatabaseManager) {
|
||||
this.dbManager = dbManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated observations
|
||||
*/
|
||||
getObservations(offset: number, limit: number, project?: string): PaginatedResult<Observation> {
|
||||
return this.paginate<Observation>(
|
||||
'observations',
|
||||
'id, session_db_id, claude_session_id, project, type, title, subtitle, text, concepts, files, prompt_number, created_at, created_at_epoch',
|
||||
offset,
|
||||
limit,
|
||||
project
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated summaries
|
||||
*/
|
||||
getSummaries(offset: number, limit: number, project?: string): PaginatedResult<Summary> {
|
||||
return this.paginate<Summary>(
|
||||
'summaries',
|
||||
'id, session_db_id, claude_session_id, project, request, completion, summary, learnings, notes, created_at, created_at_epoch',
|
||||
offset,
|
||||
limit,
|
||||
project
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated user prompts
|
||||
*/
|
||||
getPrompts(offset: number, limit: number, project?: string): PaginatedResult<UserPrompt> {
|
||||
return this.paginate<UserPrompt>(
|
||||
'user_prompts',
|
||||
'id, session_db_id, claude_session_id, project, prompt, created_at, created_at_epoch',
|
||||
offset,
|
||||
limit,
|
||||
project
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic pagination implementation (DRY)
|
||||
*/
|
||||
private paginate<T>(
|
||||
table: string,
|
||||
columns: string,
|
||||
offset: number,
|
||||
limit: number,
|
||||
project?: string
|
||||
): PaginatedResult<T> {
|
||||
const db = this.dbManager.getSessionStore().db;
|
||||
|
||||
let query = `SELECT ${columns} FROM ${table}`;
|
||||
const params: any[] = [];
|
||||
|
||||
if (project) {
|
||||
query += ' WHERE project = ?';
|
||||
params.push(project);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at_epoch DESC LIMIT ? OFFSET ?';
|
||||
params.push(limit + 1, offset); // Fetch one extra to check hasMore
|
||||
|
||||
const stmt = db.prepare(query);
|
||||
const results = stmt.all(...params) as T[];
|
||||
|
||||
return {
|
||||
items: results.slice(0, limit),
|
||||
hasMore: results.length > limit,
|
||||
offset,
|
||||
limit
|
||||
};
|
||||
}
|
||||
}
|
||||
259
src/services/worker/SDKAgent.ts
Normal file
259
src/services/worker/SDKAgent.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* SDKAgent: SDK query loop handler
|
||||
*
|
||||
* Responsibility:
|
||||
* - Spawn Claude subprocess via Agent SDK
|
||||
* - Run event-driven query loop (no polling)
|
||||
* - Process SDK responses (observations, summaries)
|
||||
* - Sync to database and Chroma
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { homedir } from 'os';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { DatabaseManager } from './DatabaseManager.js';
|
||||
import { SessionManager } from './SessionManager.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { parseObservations, parseSummary } from '../../sdk/parser.js';
|
||||
import { buildInitPrompt, buildObservationPrompt, buildSummaryPrompt } from '../../sdk/prompts.js';
|
||||
import type { ActiveSession, SDKUserMessage, PendingMessage } from '../worker-types.js';
|
||||
|
||||
// Import Agent SDK (assumes it's installed)
|
||||
// @ts-ignore - Agent SDK types may not be available
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
export class SDKAgent {
|
||||
private dbManager: DatabaseManager;
|
||||
private sessionManager: SessionManager;
|
||||
|
||||
constructor(dbManager: DatabaseManager, sessionManager: SessionManager) {
|
||||
this.dbManager = dbManager;
|
||||
this.sessionManager = sessionManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start SDK agent for a session (event-driven, no polling)
|
||||
*/
|
||||
async startSession(session: ActiveSession): Promise<void> {
|
||||
try {
|
||||
// Find Claude executable
|
||||
const claudePath = this.findClaudeExecutable();
|
||||
|
||||
// Get model ID and disallowed tools
|
||||
const modelId = this.getModelId();
|
||||
const disallowedTools = ['Bash']; // Prevent infinite loops
|
||||
|
||||
// Create message generator (event-driven)
|
||||
const messageGenerator = this.createMessageGenerator(session);
|
||||
|
||||
// Run Agent SDK query loop
|
||||
const queryResult = query({
|
||||
prompt: messageGenerator,
|
||||
options: {
|
||||
model: modelId,
|
||||
disallowedTools,
|
||||
abortController: session.abortController,
|
||||
pathToClaudeCodeExecutable: claudePath
|
||||
}
|
||||
});
|
||||
|
||||
// Process SDK messages
|
||||
for await (const message of queryResult) {
|
||||
// Handle assistant messages
|
||||
if (message.type === 'assistant') {
|
||||
const content = message.message.content;
|
||||
const textContent = Array.isArray(content)
|
||||
? content.filter((c: any) => c.type === 'text').map((c: any) => c.text).join('\n')
|
||||
: typeof content === 'string' ? content : '';
|
||||
|
||||
const responseSize = textContent.length;
|
||||
logger.dataOut('SDK', `Response received (${responseSize} chars)`, {
|
||||
sessionId: session.sessionDbId,
|
||||
promptNumber: session.lastPromptNumber
|
||||
});
|
||||
|
||||
// Parse and process response
|
||||
await this.processSDKResponse(session, textContent);
|
||||
}
|
||||
|
||||
// Log result messages
|
||||
if (message.type === 'result' && message.subtype === 'success') {
|
||||
// Usage telemetry is captured at SDK level
|
||||
}
|
||||
}
|
||||
|
||||
// Mark session complete
|
||||
const sessionDuration = Date.now() - session.startTime;
|
||||
logger.success('SDK', 'Agent completed', {
|
||||
sessionId: session.sessionDbId,
|
||||
duration: `${(sessionDuration / 1000).toFixed(1)}s`
|
||||
});
|
||||
|
||||
this.dbManager.getSessionStore().markSessionCompleted(session.sessionDbId);
|
||||
|
||||
} catch (error: any) {
|
||||
if (error.name === 'AbortError') {
|
||||
logger.warn('SDK', 'Agent aborted', { sessionId: session.sessionDbId });
|
||||
} else {
|
||||
logger.failure('SDK', 'Agent error', { sessionDbId: session.sessionDbId }, error);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
// Cleanup
|
||||
this.sessionManager.deleteSession(session.sessionDbId).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create event-driven message generator (yields messages from SessionManager)
|
||||
*/
|
||||
private async *createMessageGenerator(session: ActiveSession): AsyncIterableIterator<SDKUserMessage> {
|
||||
// Yield initial user prompt with context
|
||||
yield {
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: buildInitPrompt(session.project, session.claudeSessionId, session.userPrompt)
|
||||
},
|
||||
session_id: session.claudeSessionId,
|
||||
parent_tool_use_id: null,
|
||||
isSynthetic: true
|
||||
};
|
||||
|
||||
// Consume pending messages from SessionManager (event-driven, no polling)
|
||||
for await (const message of this.sessionManager.getMessageIterator(session.sessionDbId)) {
|
||||
if (message.type === 'observation') {
|
||||
// Update last prompt number
|
||||
if (message.prompt_number !== undefined) {
|
||||
session.lastPromptNumber = message.prompt_number;
|
||||
}
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: buildObservationPrompt({
|
||||
id: 0, // Not used in prompt
|
||||
tool_name: message.tool_name!,
|
||||
tool_input: JSON.stringify(message.tool_input),
|
||||
tool_output: JSON.stringify(message.tool_output),
|
||||
created_at_epoch: Date.now()
|
||||
})
|
||||
},
|
||||
session_id: session.claudeSessionId,
|
||||
parent_tool_use_id: null,
|
||||
isSynthetic: true
|
||||
};
|
||||
} else if (message.type === 'summarize') {
|
||||
yield {
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: buildSummaryPrompt({
|
||||
id: session.sessionDbId,
|
||||
sdk_session_id: session.sdkSessionId,
|
||||
project: session.project,
|
||||
user_prompt: session.userPrompt
|
||||
})
|
||||
},
|
||||
session_id: session.claudeSessionId,
|
||||
parent_tool_use_id: null,
|
||||
isSynthetic: true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process SDK response text (parse XML, save to database, sync to Chroma)
|
||||
*/
|
||||
private async processSDKResponse(session: ActiveSession, text: string): Promise<void> {
|
||||
// Parse observations
|
||||
const observations = parseObservations(text, session.claudeSessionId);
|
||||
|
||||
// Store observations
|
||||
for (const obs of observations) {
|
||||
const { id: obsId, createdAtEpoch } = this.dbManager.getSessionStore().storeObservation(
|
||||
session.claudeSessionId,
|
||||
session.project,
|
||||
obs,
|
||||
session.lastPromptNumber
|
||||
);
|
||||
|
||||
// Sync to Chroma (fire-and-forget)
|
||||
this.dbManager.getChromaSync().syncObservation(
|
||||
obsId,
|
||||
session.claudeSessionId,
|
||||
session.project,
|
||||
obs,
|
||||
session.lastPromptNumber,
|
||||
createdAtEpoch
|
||||
).catch(() => {});
|
||||
|
||||
logger.info('SDK', 'Observation saved', { obsId, type: obs.type });
|
||||
}
|
||||
|
||||
// Parse summary
|
||||
const summary = parseSummary(text, session.sessionDbId);
|
||||
|
||||
// Store summary
|
||||
if (summary) {
|
||||
const { id: summaryId, createdAtEpoch } = this.dbManager.getSessionStore().storeSummary(
|
||||
session.claudeSessionId,
|
||||
session.project,
|
||||
summary,
|
||||
session.lastPromptNumber
|
||||
);
|
||||
|
||||
// Sync to Chroma (fire-and-forget)
|
||||
this.dbManager.getChromaSync().syncSummary(
|
||||
summaryId,
|
||||
session.claudeSessionId,
|
||||
session.project,
|
||||
summary,
|
||||
session.lastPromptNumber,
|
||||
createdAtEpoch
|
||||
).catch(() => {});
|
||||
|
||||
logger.info('SDK', 'Summary saved', { summaryId });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Configuration Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Find Claude executable (inline, called once per session)
|
||||
*/
|
||||
private findClaudeExecutable(): string {
|
||||
const claudePath = process.env.CLAUDE_CODE_PATH ||
|
||||
execSync(process.platform === 'win32' ? 'where claude' : 'which claude', { encoding: 'utf8' })
|
||||
.trim().split('\n')[0].trim();
|
||||
|
||||
if (!claudePath) {
|
||||
throw new Error('Claude executable not found in PATH');
|
||||
}
|
||||
|
||||
return claudePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model ID from settings or environment
|
||||
*/
|
||||
private getModelId(): string {
|
||||
try {
|
||||
const settingsPath = path.join(homedir(), '.claude-mem', 'settings.json');
|
||||
if (existsSync(settingsPath)) {
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
const modelId = settings.env?.CLAUDE_MEM_MODEL;
|
||||
if (modelId) return modelId;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to env var or default
|
||||
}
|
||||
|
||||
return process.env.CLAUDE_MEM_MODEL || 'claude-haiku-4-5';
|
||||
}
|
||||
}
|
||||
83
src/services/worker/SSEBroadcaster.ts
Normal file
83
src/services/worker/SSEBroadcaster.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* SSEBroadcaster: SSE client management
|
||||
*
|
||||
* Responsibility:
|
||||
* - Manage SSE client connections
|
||||
* - Broadcast events to all connected clients
|
||||
* - Handle disconnections gracefully
|
||||
* - Single-pass broadcast (no two-step cleanup)
|
||||
*/
|
||||
|
||||
import type { Response } from 'express';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import type { SSEEvent, SSEClient } from '../worker-types.js';
|
||||
|
||||
export class SSEBroadcaster {
|
||||
private sseClients: Set<SSEClient> = new Set();
|
||||
|
||||
/**
|
||||
* Add a new SSE client connection
|
||||
*/
|
||||
addClient(res: Response): void {
|
||||
this.sseClients.add(res);
|
||||
logger.debug('WORKER', 'Client connected', { total: this.sseClients.size });
|
||||
|
||||
// Setup cleanup on disconnect
|
||||
res.on('close', () => {
|
||||
this.removeClient(res);
|
||||
});
|
||||
|
||||
// Send initial event
|
||||
this.sendToClient(res, { type: 'connected', timestamp: Date.now() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a client connection
|
||||
*/
|
||||
removeClient(res: Response): void {
|
||||
this.sseClients.delete(res);
|
||||
logger.debug('WORKER', 'Client disconnected', { total: this.sseClients.size });
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast an event to all connected clients (single-pass)
|
||||
*/
|
||||
broadcast(event: SSEEvent): void {
|
||||
if (this.sseClients.size === 0) {
|
||||
return; // Short-circuit if no clients
|
||||
}
|
||||
|
||||
const eventWithTimestamp = { ...event, timestamp: Date.now() };
|
||||
const data = `data: ${JSON.stringify(eventWithTimestamp)}\n\n`;
|
||||
|
||||
// Single-pass write + cleanup
|
||||
for (const client of this.sseClients) {
|
||||
try {
|
||||
client.write(data);
|
||||
} catch (err) {
|
||||
// Remove failed client immediately
|
||||
this.sseClients.delete(client);
|
||||
logger.debug('WORKER', 'Client removed due to write error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of connected clients
|
||||
*/
|
||||
getClientCount(): number {
|
||||
return this.sseClients.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send event to a specific client
|
||||
*/
|
||||
private sendToClient(res: Response, event: SSEEvent): void {
|
||||
const data = `data: ${JSON.stringify(event)}\n\n`;
|
||||
try {
|
||||
res.write(data);
|
||||
} catch (err) {
|
||||
this.sseClients.delete(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
182
src/services/worker/SessionManager.ts
Normal file
182
src/services/worker/SessionManager.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* SessionManager: Event-driven session lifecycle
|
||||
*
|
||||
* Responsibility:
|
||||
* - Manage active session lifecycle
|
||||
* - Handle event-driven message queues
|
||||
* - Coordinate between HTTP requests and SDK agent
|
||||
* - Zero-latency event notification (no polling)
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { DatabaseManager } from './DatabaseManager.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import type { ActiveSession, PendingMessage, ObservationData } from '../worker-types.js';
|
||||
|
||||
export class SessionManager {
|
||||
private dbManager: DatabaseManager;
|
||||
private sessions: Map<number, ActiveSession> = new Map();
|
||||
private sessionQueues: Map<number, EventEmitter> = new Map();
|
||||
|
||||
constructor(dbManager: DatabaseManager) {
|
||||
this.dbManager = dbManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a new session or return existing one
|
||||
*/
|
||||
initializeSession(sessionDbId: number): ActiveSession {
|
||||
// Check if already active
|
||||
let session = this.sessions.get(sessionDbId);
|
||||
if (session) {
|
||||
return session;
|
||||
}
|
||||
|
||||
// Fetch from database
|
||||
const dbSession = this.dbManager.getSessionById(sessionDbId);
|
||||
|
||||
// Create active session
|
||||
session = {
|
||||
sessionDbId,
|
||||
claudeSessionId: dbSession.claude_session_id,
|
||||
sdkSessionId: null,
|
||||
project: dbSession.project,
|
||||
userPrompt: dbSession.user_prompt,
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: null,
|
||||
lastPromptNumber: 0,
|
||||
startTime: Date.now()
|
||||
};
|
||||
|
||||
this.sessions.set(sessionDbId, session);
|
||||
|
||||
// Create event emitter for queue notifications
|
||||
const emitter = new EventEmitter();
|
||||
this.sessionQueues.set(sessionDbId, emitter);
|
||||
|
||||
logger.info('WORKER', 'Session initialized', { sessionDbId, project: session.project });
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active session by ID
|
||||
*/
|
||||
getSession(sessionDbId: number): ActiveSession | undefined {
|
||||
return this.sessions.get(sessionDbId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue an observation for processing (zero-latency notification)
|
||||
*/
|
||||
queueObservation(sessionDbId: number, data: ObservationData): void {
|
||||
const session = this.sessions.get(sessionDbId);
|
||||
if (!session) {
|
||||
throw new Error(`Session ${sessionDbId} not active`);
|
||||
}
|
||||
|
||||
session.pendingMessages.push({
|
||||
type: 'observation',
|
||||
tool_name: data.tool_name,
|
||||
tool_input: data.tool_input,
|
||||
tool_output: data.tool_output,
|
||||
prompt_number: data.prompt_number
|
||||
});
|
||||
|
||||
// Notify generator immediately (zero latency)
|
||||
const emitter = this.sessionQueues.get(sessionDbId);
|
||||
emitter?.emit('message');
|
||||
|
||||
logger.debug('WORKER', 'Observation queued', {
|
||||
sessionDbId,
|
||||
queueLength: session.pendingMessages.length
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a summarize request (zero-latency notification)
|
||||
*/
|
||||
queueSummarize(sessionDbId: number): void {
|
||||
const session = this.sessions.get(sessionDbId);
|
||||
if (!session) {
|
||||
throw new Error(`Session ${sessionDbId} not active`);
|
||||
}
|
||||
|
||||
session.pendingMessages.push({ type: 'summarize' });
|
||||
|
||||
const emitter = this.sessionQueues.get(sessionDbId);
|
||||
emitter?.emit('message');
|
||||
|
||||
logger.debug('WORKER', 'Summarize queued', { sessionDbId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session (abort SDK agent and cleanup)
|
||||
*/
|
||||
async deleteSession(sessionDbId: number): Promise<void> {
|
||||
const session = this.sessions.get(sessionDbId);
|
||||
if (!session) {
|
||||
return; // Already deleted
|
||||
}
|
||||
|
||||
// Abort the SDK agent
|
||||
session.abortController.abort();
|
||||
|
||||
// Wait for generator to finish
|
||||
if (session.generatorPromise) {
|
||||
await session.generatorPromise.catch(() => {});
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
this.sessions.delete(sessionDbId);
|
||||
this.sessionQueues.delete(sessionDbId);
|
||||
|
||||
logger.info('WORKER', 'Session deleted', { sessionDbId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown all active sessions
|
||||
*/
|
||||
async shutdownAll(): Promise<void> {
|
||||
const sessionIds = Array.from(this.sessions.keys());
|
||||
await Promise.all(sessionIds.map(id => this.deleteSession(id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get message iterator for SDKAgent to consume (event-driven, no polling)
|
||||
*/
|
||||
async *getMessageIterator(sessionDbId: number): AsyncIterableIterator<PendingMessage> {
|
||||
const session = this.sessions.get(sessionDbId);
|
||||
if (!session) {
|
||||
throw new Error(`Session ${sessionDbId} not active`);
|
||||
}
|
||||
|
||||
const emitter = this.sessionQueues.get(sessionDbId);
|
||||
if (!emitter) {
|
||||
throw new Error(`No emitter for session ${sessionDbId}`);
|
||||
}
|
||||
|
||||
while (!session.abortController.signal.aborted) {
|
||||
// Wait for messages if queue is empty
|
||||
if (session.pendingMessages.length === 0) {
|
||||
await new Promise<void>(resolve => {
|
||||
const handler = () => resolve();
|
||||
emitter.once('message', handler);
|
||||
|
||||
// Also listen for abort
|
||||
session.abortController.signal.addEventListener('abort', () => {
|
||||
emitter.off('message', handler);
|
||||
resolve();
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
// Yield all pending messages
|
||||
while (session.pendingMessages.length > 0) {
|
||||
const message = session.pendingMessages.shift()!;
|
||||
yield message;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
68
src/services/worker/SettingsManager.ts
Normal file
68
src/services/worker/SettingsManager.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* SettingsManager: DRY settings CRUD utility
|
||||
*
|
||||
* Responsibility:
|
||||
* - DRY helper for viewer settings CRUD
|
||||
* - Eliminates duplication in settings read/write logic
|
||||
* - Type-safe settings management
|
||||
*/
|
||||
|
||||
import { DatabaseManager } from './DatabaseManager.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import type { ViewerSettings } from '../worker-types.js';
|
||||
|
||||
export class SettingsManager {
|
||||
private dbManager: DatabaseManager;
|
||||
private readonly defaultSettings: ViewerSettings = {
|
||||
sidebarOpen: true,
|
||||
selectedProject: null,
|
||||
theme: 'system'
|
||||
};
|
||||
|
||||
constructor(dbManager: DatabaseManager) {
|
||||
this.dbManager = dbManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current viewer settings (with defaults)
|
||||
*/
|
||||
getSettings(): ViewerSettings {
|
||||
const db = this.dbManager.getSessionStore().db;
|
||||
|
||||
try {
|
||||
const stmt = db.prepare('SELECT key, value FROM viewer_settings');
|
||||
const rows = stmt.all() as Array<{ key: string; value: string }>;
|
||||
|
||||
const settings: ViewerSettings = { ...this.defaultSettings };
|
||||
for (const row of rows) {
|
||||
const key = row.key as keyof ViewerSettings;
|
||||
if (key in settings) {
|
||||
(settings as any)[key] = JSON.parse(row.value);
|
||||
}
|
||||
}
|
||||
|
||||
return settings;
|
||||
} catch (error) {
|
||||
logger.debug('WORKER', 'Failed to load settings, using defaults', {}, error as Error);
|
||||
return { ...this.defaultSettings };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update viewer settings (partial update)
|
||||
*/
|
||||
updateSettings(updates: Partial<ViewerSettings>): ViewerSettings {
|
||||
const db = this.dbManager.getSessionStore().db;
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO viewer_settings (key, value)
|
||||
VALUES (?, ?)
|
||||
`);
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
stmt.run(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
return this.getSettings();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user