feat: Refactor Settings and Viewer routes to extend BaseRouteHandler for improved error handling

- Introduced BaseRouteHandler class to centralize error handling and response management.
- Updated SettingsRoutes to use wrapHandler for automatic error logging and response.
- Refactored ViewerRoutes to extend BaseRouteHandler and utilize wrapHandler for health check and UI serving.
- Enhanced error handling in SettingsRoutes and ViewerRoutes for better maintainability and readability.
This commit is contained in:
Alex Newman
2025-12-07 22:08:06 -05:00
parent 922f04e66a
commit 9cb4b9d02a
8 changed files with 870 additions and 998 deletions
File diff suppressed because one or more lines are too long
@@ -0,0 +1,82 @@
/**
* BaseRouteHandler
*
* Base class for all route handlers providing:
* - Automatic try-catch wrapping with error logging
* - Integer parameter validation
* - Required body parameter validation
* - Standard HTTP response helpers
* - Centralized error handling
*/
import { Request, Response } from 'express';
import { logger } from '../../../utils/logger.js';
export abstract class BaseRouteHandler {
/**
* Wrap handler with automatic try-catch and error logging
*/
protected wrapHandler(
handler: (req: Request, res: Response) => void | Promise<void>
): (req: Request, res: Response) => void {
return (req: Request, res: Response): void => {
try {
const result = handler(req, res);
if (result instanceof Promise) {
result.catch(error => this.handleError(res, error as Error));
}
} catch (error) {
this.handleError(res, error as Error);
}
};
}
/**
* Parse and validate integer parameter
* Returns the integer value or sends 400 error response
*/
protected parseIntParam(req: Request, res: Response, paramName: string): number | null {
const value = parseInt(req.params[paramName], 10);
if (isNaN(value)) {
this.badRequest(res, `Invalid ${paramName}`);
return null;
}
return value;
}
/**
* Validate required body parameters
* Returns true if all required params present, sends 400 error otherwise
*/
protected validateRequired(req: Request, res: Response, params: string[]): boolean {
for (const param of params) {
if (req.body[param] === undefined || req.body[param] === null) {
this.badRequest(res, `Missing ${param}`);
return false;
}
}
return true;
}
/**
* Send 400 Bad Request response
*/
protected badRequest(res: Response, message: string): void {
res.status(400).json({ error: message });
}
/**
* Send 404 Not Found response
*/
protected notFound(res: Response, message: string): void {
res.status(404).json({ error: message });
}
/**
* Centralized error logging and response
*/
protected handleError(res: Response, error: Error, context?: string): void {
logger.failure('WORKER', context || 'Request failed', {}, error);
res.status(500).json({ error: error.message });
}
}
+114 -167
View File
@@ -11,14 +11,14 @@ import { readFileSync, statSync, existsSync } from 'fs';
import { homedir } from 'os'; import { homedir } from 'os';
import { getPackageRoot } from '../../../../shared/paths.js'; import { getPackageRoot } from '../../../../shared/paths.js';
import { getWorkerPort } from '../../../../shared/worker-utils.js'; import { getWorkerPort } from '../../../../shared/worker-utils.js';
import { logger } from '../../../../utils/logger.js';
import { PaginationHelper } from '../../PaginationHelper.js'; import { PaginationHelper } from '../../PaginationHelper.js';
import { DatabaseManager } from '../../DatabaseManager.js'; import { DatabaseManager } from '../../DatabaseManager.js';
import { SessionManager } from '../../SessionManager.js'; import { SessionManager } from '../../SessionManager.js';
import { SSEBroadcaster } from '../../SSEBroadcaster.js'; import { SSEBroadcaster } from '../../SSEBroadcaster.js';
import type { WorkerService } from '../../../worker-service.js'; import type { WorkerService } from '../../../worker-service.js';
import { BaseRouteHandler } from '../BaseRouteHandler.js';
export class DataRoutes { export class DataRoutes extends BaseRouteHandler {
constructor( constructor(
private paginationHelper: PaginationHelper, private paginationHelper: PaginationHelper,
private dbManager: DatabaseManager, private dbManager: DatabaseManager,
@@ -26,7 +26,9 @@ export class DataRoutes {
private sseBroadcaster: SSEBroadcaster, private sseBroadcaster: SSEBroadcaster,
private workerService: WorkerService, private workerService: WorkerService,
private startTime: number private startTime: number
) {} ) {
super();
}
setupRoutes(app: express.Application): void { setupRoutes(app: express.Application): void {
// Pagination endpoints // Pagination endpoints
@@ -51,233 +53,178 @@ export class DataRoutes {
/** /**
* Get paginated observations * Get paginated observations
*/ */
private handleGetObservations(req: Request, res: Response): void { private handleGetObservations = this.wrapHandler((req: Request, res: Response): void => {
try { const { offset, limit, project } = this.parsePaginationParams(req);
const { offset, limit, project } = this.parsePaginationParams(req); const result = this.paginationHelper.getObservations(offset, limit, project);
const result = this.paginationHelper.getObservations(offset, limit, project); res.json(result);
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 * Get paginated summaries
*/ */
private handleGetSummaries(req: Request, res: Response): void { private handleGetSummaries = this.wrapHandler((req: Request, res: Response): void => {
try { const { offset, limit, project } = this.parsePaginationParams(req);
const { offset, limit, project } = this.parsePaginationParams(req); const result = this.paginationHelper.getSummaries(offset, limit, project);
const result = this.paginationHelper.getSummaries(offset, limit, project); res.json(result);
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 * Get paginated user prompts
*/ */
private handleGetPrompts(req: Request, res: Response): void { private handleGetPrompts = this.wrapHandler((req: Request, res: Response): void => {
try { const { offset, limit, project } = this.parsePaginationParams(req);
const { offset, limit, project } = this.parsePaginationParams(req); const result = this.paginationHelper.getPrompts(offset, limit, project);
const result = this.paginationHelper.getPrompts(offset, limit, project); res.json(result);
res.json(result); });
} catch (error) {
logger.failure('WORKER', 'Get prompts failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/** /**
* Get observation by ID * Get observation by ID
* GET /api/observation/:id * GET /api/observation/:id
*/ */
private handleGetObservationById(req: Request, res: Response): void { private handleGetObservationById = this.wrapHandler((req: Request, res: Response): void => {
try { const id = this.parseIntParam(req, res, 'id');
const id = parseInt(req.params.id, 10); if (id === null) return;
if (isNaN(id)) {
res.status(400).json({ error: 'Invalid observation ID' });
return;
}
const store = this.dbManager.getSessionStore(); const store = this.dbManager.getSessionStore();
const observation = store.getObservationById(id); const observation = store.getObservationById(id);
if (!observation) { if (!observation) {
res.status(404).json({ error: `Observation #${id} not found` }); this.notFound(res, `Observation #${id} not found`);
return; return;
}
res.json(observation);
} catch (error) {
logger.failure('WORKER', 'Get observation by ID failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
} }
}
res.json(observation);
});
/** /**
* Get session by ID * Get session by ID
* GET /api/session/:id * GET /api/session/:id
*/ */
private handleGetSessionById(req: Request, res: Response): void { private handleGetSessionById = this.wrapHandler((req: Request, res: Response): void => {
try { const id = this.parseIntParam(req, res, 'id');
const id = parseInt(req.params.id, 10); if (id === null) return;
if (isNaN(id)) {
res.status(400).json({ error: 'Invalid session ID' });
return;
}
const store = this.dbManager.getSessionStore(); const store = this.dbManager.getSessionStore();
const sessions = store.getSessionSummariesByIds([id]); const sessions = store.getSessionSummariesByIds([id]);
if (sessions.length === 0) { if (sessions.length === 0) {
res.status(404).json({ error: `Session #${id} not found` }); this.notFound(res, `Session #${id} not found`);
return; return;
}
res.json(sessions[0]);
} catch (error) {
logger.failure('WORKER', 'Get session by ID failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
} }
}
res.json(sessions[0]);
});
/** /**
* Get user prompt by ID * Get user prompt by ID
* GET /api/prompt/:id * GET /api/prompt/:id
*/ */
private handleGetPromptById(req: Request, res: Response): void { private handleGetPromptById = this.wrapHandler((req: Request, res: Response): void => {
try { const id = this.parseIntParam(req, res, 'id');
const id = parseInt(req.params.id, 10); if (id === null) return;
if (isNaN(id)) {
res.status(400).json({ error: 'Invalid prompt ID' });
return;
}
const store = this.dbManager.getSessionStore(); const store = this.dbManager.getSessionStore();
const prompts = store.getUserPromptsByIds([id]); const prompts = store.getUserPromptsByIds([id]);
if (prompts.length === 0) { if (prompts.length === 0) {
res.status(404).json({ error: `Prompt #${id} not found` }); this.notFound(res, `Prompt #${id} not found`);
return; return;
}
res.json(prompts[0]);
} catch (error) {
logger.failure('WORKER', 'Get prompt by ID failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
} }
}
res.json(prompts[0]);
});
/** /**
* Get database statistics (with worker metadata) * Get database statistics (with worker metadata)
*/ */
private handleGetStats(req: Request, res: Response): void { private handleGetStats = this.wrapHandler((req: Request, res: Response): void => {
try { const db = this.dbManager.getSessionStore().db;
const db = this.dbManager.getSessionStore().db;
// Read version from package.json // Read version from package.json
const packageRoot = getPackageRoot(); const packageRoot = getPackageRoot();
const packageJsonPath = path.join(packageRoot, 'package.json'); const packageJsonPath = path.join(packageRoot, 'package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
const version = packageJson.version; const version = packageJson.version;
// Get database stats // Get database stats
const totalObservations = db.prepare('SELECT COUNT(*) as count FROM observations').get() as { count: number }; const totalObservations = db.prepare('SELECT COUNT(*) as count FROM observations').get() as { count: number };
const totalSessions = db.prepare('SELECT COUNT(*) as count FROM sdk_sessions').get() as { count: number }; const totalSessions = db.prepare('SELECT COUNT(*) as count FROM sdk_sessions').get() as { count: number };
const totalSummaries = db.prepare('SELECT COUNT(*) as count FROM session_summaries').get() as { count: number }; const totalSummaries = db.prepare('SELECT COUNT(*) as count FROM session_summaries').get() as { count: number };
// Get database file size and path // Get database file size and path
const dbPath = path.join(homedir(), '.claude-mem', 'claude-mem.db'); const dbPath = path.join(homedir(), '.claude-mem', 'claude-mem.db');
let dbSize = 0; let dbSize = 0;
if (existsSync(dbPath)) { if (existsSync(dbPath)) {
dbSize = statSync(dbPath).size; dbSize = statSync(dbPath).size;
}
// Worker metadata
const uptime = Math.floor((Date.now() - this.startTime) / 1000);
const activeSessions = this.sessionManager.getActiveSessionCount();
const sseClients = this.sseBroadcaster.getClientCount();
res.json({
worker: {
version,
uptime,
activeSessions,
sseClients,
port: getWorkerPort()
},
database: {
path: dbPath,
size: dbSize,
observations: totalObservations.count,
sessions: totalSessions.count,
summaries: totalSummaries.count
}
});
} catch (error) {
logger.failure('WORKER', 'Get stats failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
} }
}
// Worker metadata
const uptime = Math.floor((Date.now() - this.startTime) / 1000);
const activeSessions = this.sessionManager.getActiveSessionCount();
const sseClients = this.sseBroadcaster.getClientCount();
res.json({
worker: {
version,
uptime,
activeSessions,
sseClients,
port: getWorkerPort()
},
database: {
path: dbPath,
size: dbSize,
observations: totalObservations.count,
sessions: totalSessions.count,
summaries: totalSummaries.count
}
});
});
/** /**
* Get list of distinct projects from observations * Get list of distinct projects from observations
* GET /api/projects * GET /api/projects
*/ */
private handleGetProjects(req: Request, res: Response): void { private handleGetProjects = this.wrapHandler((req: Request, res: Response): void => {
try { const db = this.dbManager.getSessionStore().db;
const db = this.dbManager.getSessionStore().db;
const rows = db.prepare(` const rows = db.prepare(`
SELECT DISTINCT project SELECT DISTINCT project
FROM observations FROM observations
WHERE project IS NOT NULL WHERE project IS NOT NULL
GROUP BY project GROUP BY project
ORDER BY MAX(created_at_epoch) DESC ORDER BY MAX(created_at_epoch) DESC
`).all() as Array<{ project: string }>; `).all() as Array<{ project: string }>;
const projects = rows.map(row => row.project); const projects = rows.map(row => row.project);
res.json({ projects }); res.json({ projects });
} catch (error) { });
logger.failure('WORKER', 'Get projects failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/** /**
* Get current processing status * Get current processing status
* GET /api/processing-status * GET /api/processing-status
*/ */
private handleGetProcessingStatus(req: Request, res: Response): void { private handleGetProcessingStatus = this.wrapHandler((req: Request, res: Response): void => {
const isProcessing = this.sessionManager.isAnySessionProcessing(); const isProcessing = this.sessionManager.isAnySessionProcessing();
const queueDepth = this.sessionManager.getTotalActiveWork(); // Includes queued + actively processing const queueDepth = this.sessionManager.getTotalActiveWork(); // Includes queued + actively processing
res.json({ isProcessing, queueDepth }); res.json({ isProcessing, queueDepth });
} });
/** /**
* Set processing status (called by hooks) * Set processing status (called by hooks)
* NOTE: This now broadcasts computed status based on active processing (ignores input) * NOTE: This now broadcasts computed status based on active processing (ignores input)
*/ */
private handleSetProcessing(req: Request, res: Response): void { private handleSetProcessing = this.wrapHandler((req: Request, res: Response): void => {
try { // Broadcast current computed status (ignores manual input)
// Broadcast current computed status (ignores manual input) this.workerService.broadcastProcessingStatus();
this.workerService.broadcastProcessingStatus();
const isProcessing = this.sessionManager.isAnySessionProcessing(); const isProcessing = this.sessionManager.isAnySessionProcessing();
const queueDepth = this.sessionManager.getTotalQueueDepth(); const queueDepth = this.sessionManager.getTotalQueueDepth();
const activeSessions = this.sessionManager.getActiveSessionCount(); const activeSessions = this.sessionManager.getActiveSessionCount();
logger.debug('WORKER', 'Processing status broadcast', { isProcessing, queueDepth, activeSessions });
res.json({ status: 'ok', isProcessing }); res.json({ status: 'ok', isProcessing });
} catch (error) { });
logger.failure('WORKER', 'Failed to broadcast processing status', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/** /**
* Parse pagination parameters from request query * Parse pagination parameters from request query
+114 -198
View File
@@ -6,13 +6,15 @@
*/ */
import express, { Request, Response } from 'express'; import express, { Request, Response } from 'express';
import { logger } from '../../../../utils/logger.js';
import { SearchManager } from '../../SearchManager.js'; import { SearchManager } from '../../SearchManager.js';
import { BaseRouteHandler } from '../BaseRouteHandler.js';
export class SearchRoutes { export class SearchRoutes extends BaseRouteHandler {
constructor( constructor(
private searchManager: SearchManager private searchManager: SearchManager
) {} ) {
super();
}
setupRoutes(app: express.Application): void { setupRoutes(app: express.Application): void {
// Unified endpoints (new consolidated API) // Unified endpoints (new consolidated API)
@@ -45,223 +47,150 @@ export class SearchRoutes {
* Unified search (observations + sessions + prompts) * Unified search (observations + sessions + prompts)
* GET /api/search?query=...&type=observations&format=index&limit=20 * GET /api/search?query=...&type=observations&format=index&limit=20
*/ */
private async handleUnifiedSearch(req: Request, res: Response): Promise<void> { private handleUnifiedSearch = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
try { const result = await this.searchManager.search(req.query);
const result = await this.searchManager.search(req.query); res.json(result.content);
res.json(result.content); });
} catch (error) {
logger.failure('WORKER', 'Unified search failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/** /**
* Unified timeline (anchor or query-based) * Unified timeline (anchor or query-based)
* GET /api/timeline?anchor=123 OR GET /api/timeline?query=... * GET /api/timeline?anchor=123 OR GET /api/timeline?query=...
*/ */
private async handleUnifiedTimeline(req: Request, res: Response): Promise<void> { private handleUnifiedTimeline = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
try { const result = await this.searchManager.timeline(req.query);
const result = await this.searchManager.timeline(req.query); res.json(result.content);
res.json(result.content); });
} catch (error) {
logger.failure('WORKER', 'Unified timeline failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/** /**
* Semantic shortcut for finding decision observations * Semantic shortcut for finding decision observations
* GET /api/decisions?format=index&limit=20 * GET /api/decisions?format=index&limit=20
*/ */
private async handleDecisions(req: Request, res: Response): Promise<void> { private handleDecisions = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
try { const result = await this.searchManager.decisions(req.query);
const result = await this.searchManager.decisions(req.query); res.json(result.content);
res.json(result.content); });
} catch (error) {
logger.failure('WORKER', 'Decisions search failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/** /**
* Semantic shortcut for finding change-related observations * Semantic shortcut for finding change-related observations
* GET /api/changes?format=index&limit=20 * GET /api/changes?format=index&limit=20
*/ */
private async handleChanges(req: Request, res: Response): Promise<void> { private handleChanges = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
try { const result = await this.searchManager.changes(req.query);
const result = await this.searchManager.changes(req.query); res.json(result.content);
res.json(result.content); });
} catch (error) {
logger.failure('WORKER', 'Changes search failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/** /**
* Semantic shortcut for finding "how it works" explanations * Semantic shortcut for finding "how it works" explanations
* GET /api/how-it-works?format=index&limit=20 * GET /api/how-it-works?format=index&limit=20
*/ */
private async handleHowItWorks(req: Request, res: Response): Promise<void> { private handleHowItWorks = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
try { const result = await this.searchManager.howItWorks(req.query);
const result = await this.searchManager.howItWorks(req.query); res.json(result.content);
res.json(result.content); });
} catch (error) {
logger.failure('WORKER', 'How it works search failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/** /**
* Search observations (use /api/search?type=observations instead) * Search observations (use /api/search?type=observations instead)
* GET /api/search/observations?query=...&format=index&limit=20&project=... * GET /api/search/observations?query=...&format=index&limit=20&project=...
*/ */
private async handleSearchObservations(req: Request, res: Response): Promise<void> { private handleSearchObservations = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
try { const result = await this.searchManager.searchObservations(req.query);
const result = await this.searchManager.searchObservations(req.query); res.json(result.content);
res.json(result.content); });
} catch (error) {
logger.failure('WORKER', 'Search failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/** /**
* Search session summaries * Search session summaries
* GET /api/search/sessions?query=...&format=index&limit=20 * GET /api/search/sessions?query=...&format=index&limit=20
*/ */
private async handleSearchSessions(req: Request, res: Response): Promise<void> { private handleSearchSessions = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
try { const result = await this.searchManager.searchSessions(req.query);
const result = await this.searchManager.searchSessions(req.query); res.json(result.content);
res.json(result.content); });
} catch (error) {
logger.failure('WORKER', 'Search failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/** /**
* Search user prompts * Search user prompts
* GET /api/search/prompts?query=...&format=index&limit=20 * GET /api/search/prompts?query=...&format=index&limit=20
*/ */
private async handleSearchPrompts(req: Request, res: Response): Promise<void> { private handleSearchPrompts = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
try { const result = await this.searchManager.searchUserPrompts(req.query);
const result = await this.searchManager.searchUserPrompts(req.query); res.json(result.content);
res.json(result.content); });
} catch (error) {
logger.failure('WORKER', 'Search failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/** /**
* Search observations by concept * Search observations by concept
* GET /api/search/by-concept?concept=discovery&format=index&limit=5 * GET /api/search/by-concept?concept=discovery&format=index&limit=5
*/ */
private async handleSearchByConcept(req: Request, res: Response): Promise<void> { private handleSearchByConcept = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
try { const result = await this.searchManager.findByConcept(req.query);
const result = await this.searchManager.findByConcept(req.query); res.json(result.content);
res.json(result.content); });
} catch (error) {
logger.failure('WORKER', 'Search failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/** /**
* Search by file path * Search by file path
* GET /api/search/by-file?filePath=...&format=index&limit=10 * GET /api/search/by-file?filePath=...&format=index&limit=10
*/ */
private async handleSearchByFile(req: Request, res: Response): Promise<void> { private handleSearchByFile = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
try { const result = await this.searchManager.findByFile(req.query);
const result = await this.searchManager.findByFile(req.query); res.json(result.content);
res.json(result.content); });
} catch (error) {
logger.failure('WORKER', 'Search failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/** /**
* Search observations by type * Search observations by type
* GET /api/search/by-type?type=bugfix&format=index&limit=10 * GET /api/search/by-type?type=bugfix&format=index&limit=10
*/ */
private async handleSearchByType(req: Request, res: Response): Promise<void> { private handleSearchByType = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
try { const result = await this.searchManager.findByType(req.query);
const result = await this.searchManager.findByType(req.query); res.json(result.content);
res.json(result.content); });
} catch (error) {
logger.failure('WORKER', 'Search failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/** /**
* Get recent context (summaries and observations for a project) * Get recent context (summaries and observations for a project)
* GET /api/context/recent?project=...&limit=3 * GET /api/context/recent?project=...&limit=3
*/ */
private async handleGetRecentContext(req: Request, res: Response): Promise<void> { private handleGetRecentContext = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
try { const result = await this.searchManager.getRecentContext(req.query);
const result = await this.searchManager.getRecentContext(req.query); res.json(result.content);
res.json(result.content); });
} catch (error) {
logger.failure('WORKER', 'Search failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/** /**
* Get context timeline around an anchor point * Get context timeline around an anchor point
* GET /api/context/timeline?anchor=123&depth_before=10&depth_after=10&project=... * GET /api/context/timeline?anchor=123&depth_before=10&depth_after=10&project=...
*/ */
private async handleGetContextTimeline(req: Request, res: Response): Promise<void> { private handleGetContextTimeline = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
try { const result = await this.searchManager.getContextTimeline(req.query);
const result = await this.searchManager.getContextTimeline(req.query); res.json(result.content);
res.json(result.content); });
} catch (error) {
logger.failure('WORKER', 'Search failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/** /**
* Generate context preview for settings modal * Generate context preview for settings modal
* GET /api/context/preview?project=... * GET /api/context/preview?project=...
*/ */
private async handleContextPreview(req: Request, res: Response): Promise<void> { private handleContextPreview = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
try { const projectName = req.query.project as string;
const projectName = req.query.project as string;
if (!projectName) { if (!projectName) {
res.status(400).json({ error: 'Project parameter is required' }); this.badRequest(res, 'Project parameter is required');
return; return;
}
// Import context generator (runs in worker, has access to database)
const { generateContext } = await import('../../../context-generator.js');
// Use project name as CWD (generateContext uses path.basename to get project)
const cwd = `/preview/${projectName}`;
// Generate context with colors for terminal display
const contextText = await generateContext(
{
session_id: 'preview-' + Date.now(),
cwd: cwd
},
true // useColors=true for ANSI terminal output
);
// Return as plain text
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(contextText);
} catch (error) {
logger.failure('WORKER', 'Context preview generation failed', {}, error as Error);
res.status(500).json({
error: 'Failed to generate context preview',
message: (error as Error).message
});
} }
}
// Import context generator (runs in worker, has access to database)
const { generateContext } = await import('../../../context-generator.js');
// Use project name as CWD (generateContext uses path.basename to get project)
const cwd = `/preview/${projectName}`;
// Generate context with colors for terminal display
const contextText = await generateContext(
{
session_id: 'preview-' + Date.now(),
cwd: cwd
},
true // useColors=true for ANSI terminal output
);
// Return as plain text
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(contextText);
});
/** /**
* Context injection endpoint for hooks * Context injection endpoint for hooks
@@ -270,62 +199,49 @@ export class SearchRoutes {
* Returns pre-formatted context string ready for display. * Returns pre-formatted context string ready for display.
* Use colors=true for ANSI-colored terminal output. * Use colors=true for ANSI-colored terminal output.
*/ */
private async handleContextInject(req: Request, res: Response): Promise<void> { private handleContextInject = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
try { const projectName = req.query.project as string;
const projectName = req.query.project as string; const useColors = req.query.colors === 'true';
const useColors = req.query.colors === 'true';
if (!projectName) { if (!projectName) {
res.status(400).json({ error: 'Project parameter is required' }); this.badRequest(res, 'Project parameter is required');
return; return;
}
// Import context generator (runs in worker, has access to database)
const { generateContext } = await import('../../../context-generator.js');
// Use project name as CWD (generateContext uses path.basename to get project)
const cwd = `/context/${projectName}`;
// Generate context
const contextText = await generateContext(
{
session_id: 'context-inject-' + Date.now(),
cwd: cwd
},
useColors
);
// Return as plain text
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(contextText);
} catch (error) {
logger.failure('WORKER', 'Context injection failed', {}, error as Error);
res.status(500).json({
error: 'Failed to generate context',
message: (error as Error).message
});
} }
}
// Import context generator (runs in worker, has access to database)
const { generateContext } = await import('../../../context-generator.js');
// Use project name as CWD (generateContext uses path.basename to get project)
const cwd = `/context/${projectName}`;
// Generate context
const contextText = await generateContext(
{
session_id: 'context-inject-' + Date.now(),
cwd: cwd
},
useColors
);
// Return as plain text
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(contextText);
});
/** /**
* Get timeline by query (search first, then get timeline around best match) * Get timeline by query (search first, then get timeline around best match)
* GET /api/timeline/by-query?query=...&mode=auto&depth_before=10&depth_after=10 * GET /api/timeline/by-query?query=...&mode=auto&depth_before=10&depth_after=10
*/ */
private async handleGetTimelineByQuery(req: Request, res: Response): Promise<void> { private handleGetTimelineByQuery = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
try { const result = await this.searchManager.getTimelineByQuery(req.query);
const result = await this.searchManager.getTimelineByQuery(req.query); res.json(result.content);
res.json(result.content); });
} catch (error) {
logger.failure('WORKER', 'Search failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/** /**
* Get search help documentation * Get search help documentation
* GET /api/search/help * GET /api/search/help
*/ */
private handleSearchHelp(req: Request, res: Response): void { private handleSearchHelp = this.wrapHandler((req: Request, res: Response): void => {
res.json({ res.json({
title: 'Claude-Mem Search API', title: 'Claude-Mem Search API',
description: 'HTTP API for searching persistent memory', description: 'HTTP API for searching persistent memory',
@@ -440,5 +356,5 @@ export class SearchRoutes {
'curl "http://localhost:37777/api/context/timeline?anchor=123&depth_before=5&depth_after=5"' 'curl "http://localhost:37777/api/context/timeline?anchor=123&depth_before=5&depth_after=5"'
] ]
}); });
} });
} }
+302 -340
View File
@@ -14,15 +14,18 @@ import { DatabaseManager } from '../../DatabaseManager.js';
import { SDKAgent } from '../../SDKAgent.js'; import { SDKAgent } from '../../SDKAgent.js';
import { SSEBroadcaster } from '../../SSEBroadcaster.js'; import { SSEBroadcaster } from '../../SSEBroadcaster.js';
import type { WorkerService } from '../../../worker-service.js'; import type { WorkerService } from '../../../worker-service.js';
import { BaseRouteHandler } from '../BaseRouteHandler.js';
export class SessionRoutes { export class SessionRoutes extends BaseRouteHandler {
constructor( constructor(
private sessionManager: SessionManager, private sessionManager: SessionManager,
private dbManager: DatabaseManager, private dbManager: DatabaseManager,
private sdkAgent: SDKAgent, private sdkAgent: SDKAgent,
private sseBroadcaster: SSEBroadcaster, private sseBroadcaster: SSEBroadcaster,
private workerService: WorkerService private workerService: WorkerService
) {} ) {
super();
}
/** /**
* Ensures SDK agent generator is running for a session * Ensures SDK agent generator is running for a session
@@ -66,322 +69,293 @@ export class SessionRoutes {
/** /**
* Initialize a new session * Initialize a new session
*/ */
private handleSessionInit(req: Request, res: Response): void { private handleSessionInit = this.wrapHandler((req: Request, res: Response): void => {
try { const sessionDbId = this.parseIntParam(req, res, 'sessionDbId');
const sessionDbId = parseInt(req.params.sessionDbId, 10); if (sessionDbId === null) return;
const { userPrompt, promptNumber } = req.body;
const session = this.sessionManager.initializeSession(sessionDbId, userPrompt, promptNumber);
// Get the latest user_prompt for this session to sync to Chroma const { userPrompt, promptNumber } = req.body;
const latestPrompt = this.dbManager.getSessionStore().getLatestUserPrompt(session.claudeSessionId); const session = this.sessionManager.initializeSession(sessionDbId, userPrompt, promptNumber);
// Broadcast new prompt to SSE clients (for web UI) // Get the latest user_prompt for this session to sync to Chroma
if (latestPrompt) { const latestPrompt = this.dbManager.getSessionStore().getLatestUserPrompt(session.claudeSessionId);
this.sseBroadcaster.broadcast({
type: 'new_prompt',
prompt: {
id: latestPrompt.id,
claude_session_id: latestPrompt.claude_session_id,
project: latestPrompt.project,
prompt_number: latestPrompt.prompt_number,
prompt_text: latestPrompt.prompt_text,
created_at_epoch: latestPrompt.created_at_epoch
}
});
// Start activity indicator immediately when prompt arrives (work is about to begin) // Broadcast new prompt to SSE clients (for web UI)
this.sseBroadcaster.broadcast({ if (latestPrompt) {
type: 'processing_status',
isProcessing: true
});
// Sync user prompt to Chroma with error logging
const chromaStart = Date.now();
const promptText = latestPrompt.prompt_text;
this.dbManager.getChromaSync().syncUserPrompt(
latestPrompt.id,
latestPrompt.sdk_session_id,
latestPrompt.project,
promptText,
latestPrompt.prompt_number,
latestPrompt.created_at_epoch
).then(() => {
const chromaDuration = Date.now() - chromaStart;
const truncatedPrompt = promptText.length > 60
? promptText.substring(0, 60) + '...'
: promptText;
logger.debug('CHROMA', 'User prompt synced', {
promptId: latestPrompt.id,
duration: `${chromaDuration}ms`,
prompt: truncatedPrompt
});
}).catch(err => {
logger.error('CHROMA', 'Failed to sync user_prompt', {
promptId: latestPrompt.id,
sessionId: sessionDbId
}, err);
});
}
// Broadcast processing status (based on queue depth)
this.workerService.broadcastProcessingStatus();
// Start SDK agent in background (pass worker ref for spinner control)
logger.info('SESSION', 'Generator starting', {
sessionId: sessionDbId,
project: session.project,
promptNum: session.lastPromptNumber
});
session.generatorPromise = this.sdkAgent.startSession(session, this.workerService)
.catch(err => {
logger.failure('SDK', 'SDK agent error', { sessionId: sessionDbId }, err);
})
.finally(() => {
// Clear generator reference when completed
logger.info('SESSION', `Generator finished`, { sessionId: sessionDbId });
session.generatorPromise = null;
// Broadcast status change (generator finished, may stop spinner)
this.workerService.broadcastProcessingStatus();
});
// Broadcast SSE event
this.sseBroadcaster.broadcast({ this.sseBroadcaster.broadcast({
type: 'session_started', type: 'new_prompt',
sessionDbId, prompt: {
project: session.project id: latestPrompt.id,
claude_session_id: latestPrompt.claude_session_id,
project: latestPrompt.project,
prompt_number: latestPrompt.prompt_number,
prompt_text: latestPrompt.prompt_text,
created_at_epoch: latestPrompt.created_at_epoch
}
}); });
res.json({ status: 'initialized', sessionDbId, port: getWorkerPort() }); // Start activity indicator immediately when prompt arrives (work is about to begin)
} catch (error) { this.sseBroadcaster.broadcast({
logger.failure('WORKER', 'Session init failed', {}, error as Error); type: 'processing_status',
res.status(500).json({ error: (error as Error).message }); isProcessing: true
});
// Sync user prompt to Chroma with error logging
const chromaStart = Date.now();
const promptText = latestPrompt.prompt_text;
this.dbManager.getChromaSync().syncUserPrompt(
latestPrompt.id,
latestPrompt.sdk_session_id,
latestPrompt.project,
promptText,
latestPrompt.prompt_number,
latestPrompt.created_at_epoch
).then(() => {
const chromaDuration = Date.now() - chromaStart;
const truncatedPrompt = promptText.length > 60
? promptText.substring(0, 60) + '...'
: promptText;
logger.debug('CHROMA', 'User prompt synced', {
promptId: latestPrompt.id,
duration: `${chromaDuration}ms`,
prompt: truncatedPrompt
});
}).catch(err => {
logger.error('CHROMA', 'Failed to sync user_prompt', {
promptId: latestPrompt.id,
sessionId: sessionDbId
}, err);
});
} }
}
// Broadcast processing status (based on queue depth)
this.workerService.broadcastProcessingStatus();
// Start SDK agent in background (pass worker ref for spinner control)
logger.info('SESSION', 'Generator starting', {
sessionId: sessionDbId,
project: session.project,
promptNum: session.lastPromptNumber
});
session.generatorPromise = this.sdkAgent.startSession(session, this.workerService)
.catch(err => {
logger.failure('SDK', 'SDK agent error', { sessionId: sessionDbId }, err);
})
.finally(() => {
// Clear generator reference when completed
logger.info('SESSION', `Generator finished`, { sessionId: sessionDbId });
session.generatorPromise = null;
// Broadcast status change (generator finished, may stop spinner)
this.workerService.broadcastProcessingStatus();
});
// Broadcast SSE event
this.sseBroadcaster.broadcast({
type: 'session_started',
sessionDbId,
project: session.project
});
res.json({ status: 'initialized', sessionDbId, port: getWorkerPort() });
});
/** /**
* Queue observations for processing * Queue observations for processing
* CRITICAL: Ensures SDK agent is running to process the queue (ALWAYS SAVE EVERYTHING) * CRITICAL: Ensures SDK agent is running to process the queue (ALWAYS SAVE EVERYTHING)
*/ */
private handleObservations(req: Request, res: Response): void { private handleObservations = this.wrapHandler((req: Request, res: Response): void => {
try { const sessionDbId = this.parseIntParam(req, res, 'sessionDbId');
const sessionDbId = parseInt(req.params.sessionDbId, 10); if (sessionDbId === null) return;
const { tool_name, tool_input, tool_response, prompt_number, cwd } = req.body;
this.sessionManager.queueObservation(sessionDbId, { const { tool_name, tool_input, tool_response, prompt_number, cwd } = req.body;
tool_name,
tool_input,
tool_response,
prompt_number,
cwd
});
// CRITICAL: Ensure SDK agent is running to consume the queue this.sessionManager.queueObservation(sessionDbId, {
this.ensureGeneratorRunning(sessionDbId, 'observation'); tool_name,
tool_input,
tool_response,
prompt_number,
cwd
});
// Broadcast activity status (queue depth changed) // CRITICAL: Ensure SDK agent is running to consume the queue
this.workerService.broadcastProcessingStatus(); this.ensureGeneratorRunning(sessionDbId, 'observation');
// Broadcast SSE event // Broadcast activity status (queue depth changed)
this.sseBroadcaster.broadcast({ this.workerService.broadcastProcessingStatus();
type: 'observation_queued',
sessionDbId
});
res.json({ status: 'queued' }); // Broadcast SSE event
} catch (error) { this.sseBroadcaster.broadcast({
logger.failure('WORKER', 'Observation queuing failed', {}, error as Error); type: 'observation_queued',
res.status(500).json({ error: (error as Error).message }); sessionDbId
} });
}
res.json({ status: 'queued' });
});
/** /**
* Queue summarize request * Queue summarize request
* CRITICAL: Ensures SDK agent is running to process the queue (ALWAYS SAVE EVERYTHING) * CRITICAL: Ensures SDK agent is running to process the queue (ALWAYS SAVE EVERYTHING)
*/ */
private handleSummarize(req: Request, res: Response): void { private handleSummarize = this.wrapHandler((req: Request, res: Response): void => {
try { const sessionDbId = this.parseIntParam(req, res, 'sessionDbId');
const sessionDbId = parseInt(req.params.sessionDbId, 10); if (sessionDbId === null) return;
const { last_user_message, last_assistant_message } = req.body;
this.sessionManager.queueSummarize(sessionDbId, last_user_message, last_assistant_message); const { last_user_message, last_assistant_message } = req.body;
// CRITICAL: Ensure SDK agent is running to consume the queue this.sessionManager.queueSummarize(sessionDbId, last_user_message, last_assistant_message);
this.ensureGeneratorRunning(sessionDbId, 'summarize');
// Broadcast activity status (queue depth changed) // CRITICAL: Ensure SDK agent is running to consume the queue
this.workerService.broadcastProcessingStatus(); this.ensureGeneratorRunning(sessionDbId, 'summarize');
res.json({ status: 'queued' }); // Broadcast activity status (queue depth changed)
} catch (error) { this.workerService.broadcastProcessingStatus();
logger.failure('WORKER', 'Summarize queuing failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message }); res.json({ status: 'queued' });
} });
}
/** /**
* Get session status * Get session status
*/ */
private handleSessionStatus(req: Request, res: Response): void { private handleSessionStatus = this.wrapHandler((req: Request, res: Response): void => {
try { const sessionDbId = this.parseIntParam(req, res, 'sessionDbId');
const sessionDbId = parseInt(req.params.sessionDbId, 10); if (sessionDbId === null) return;
const session = this.sessionManager.getSession(sessionDbId);
if (!session) { const session = this.sessionManager.getSession(sessionDbId);
res.json({ status: 'not_found' });
return;
}
res.json({ if (!session) {
status: 'active', res.json({ status: 'not_found' });
sessionDbId, return;
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 });
} }
}
res.json({
status: 'active',
sessionDbId,
project: session.project,
queueLength: session.pendingMessages.length,
uptime: Date.now() - session.startTime
});
});
/** /**
* Delete a session * Delete a session
*/ */
private async handleSessionDelete(req: Request, res: Response): Promise<void> { private handleSessionDelete = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
try { const sessionDbId = this.parseIntParam(req, res, 'sessionDbId');
const sessionDbId = parseInt(req.params.sessionDbId, 10); if (sessionDbId === null) return;
await this.sessionManager.deleteSession(sessionDbId);
// Mark session complete in database await this.sessionManager.deleteSession(sessionDbId);
this.dbManager.markSessionComplete(sessionDbId);
// Broadcast SSE event // Mark session complete in database
this.sseBroadcaster.broadcast({ this.dbManager.markSessionComplete(sessionDbId);
type: 'session_completed',
sessionDbId
});
res.json({ status: 'deleted' }); // Broadcast SSE event
} catch (error) { this.sseBroadcaster.broadcast({
logger.failure('WORKER', 'Session delete failed', {}, error as Error); type: 'session_completed',
res.status(500).json({ error: (error as Error).message }); sessionDbId
} });
}
res.json({ status: 'deleted' });
});
/** /**
* Complete a session (backward compatibility for cleanup-hook) * Complete a session (backward compatibility for cleanup-hook)
* cleanup-hook expects POST /sessions/:sessionDbId/complete instead of DELETE * cleanup-hook expects POST /sessions/:sessionDbId/complete instead of DELETE
*/ */
private async handleSessionComplete(req: Request, res: Response): Promise<void> { private handleSessionComplete = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
try { const sessionDbId = this.parseIntParam(req, res, 'sessionDbId');
const sessionDbId = parseInt(req.params.sessionDbId, 10); if (sessionDbId === null) return;
if (isNaN(sessionDbId)) {
res.status(400).json({ success: false, error: 'Invalid session ID' });
return;
}
await this.sessionManager.deleteSession(sessionDbId); await this.sessionManager.deleteSession(sessionDbId);
// Mark session complete in database // Mark session complete in database
this.dbManager.markSessionComplete(sessionDbId); this.dbManager.markSessionComplete(sessionDbId);
// Broadcast processing status (based on queue depth) // Broadcast processing status (based on queue depth)
this.workerService.broadcastProcessingStatus(); this.workerService.broadcastProcessingStatus();
// Broadcast SSE event // Broadcast SSE event
this.sseBroadcaster.broadcast({ this.sseBroadcaster.broadcast({
type: 'session_completed', type: 'session_completed',
timestamp: Date.now(), timestamp: Date.now(),
sessionDbId sessionDbId
}); });
res.json({ success: true }); res.json({ success: true });
} catch (error) { });
logger.failure('WORKER', 'Session complete failed', {}, error as Error);
res.status(500).json({ success: false, error: String(error) });
}
}
/** /**
* Queue observations by claudeSessionId (post-tool-use-hook uses this) * Queue observations by claudeSessionId (post-tool-use-hook uses this)
* POST /api/sessions/observations * POST /api/sessions/observations
* Body: { claudeSessionId, tool_name, tool_input, tool_response, cwd } * Body: { claudeSessionId, tool_name, tool_input, tool_response, cwd }
*/ */
private handleObservationsByClaudeId(req: Request, res: Response): void { private handleObservationsByClaudeId = this.wrapHandler((req: Request, res: Response): void => {
try { const { claudeSessionId, tool_name, tool_input, tool_response, cwd } = req.body;
const { claudeSessionId, tool_name, tool_input, tool_response, cwd } = req.body;
if (!claudeSessionId) { if (!claudeSessionId) {
res.status(400).json({ error: 'Missing claudeSessionId' }); return this.badRequest(res, 'Missing claudeSessionId');
return;
}
const store = this.dbManager.getSessionStore();
// Get or create session
const sessionDbId = store.createSDKSession(claudeSessionId, '', '');
const promptNumber = store.getPromptCounter(sessionDbId);
// Privacy check: skip if user prompt was entirely private
const userPrompt = store.getUserPrompt(claudeSessionId, promptNumber);
if (!userPrompt || userPrompt.trim() === '') {
logger.debug('HOOK', 'Skipping observation - user prompt was entirely private', {
sessionId: sessionDbId,
promptNumber,
tool_name
});
res.json({ status: 'skipped', reason: 'private' });
return;
}
// Strip memory tags from tool_input and tool_response
let cleanedToolInput = '{}';
let cleanedToolResponse = '{}';
try {
cleanedToolInput = tool_input !== undefined
? stripMemoryTagsFromJson(JSON.stringify(tool_input))
: '{}';
} catch (error) {
cleanedToolInput = '{"error": "Failed to serialize tool_input"}';
}
try {
cleanedToolResponse = tool_response !== undefined
? stripMemoryTagsFromJson(JSON.stringify(tool_response))
: '{}';
} catch (error) {
cleanedToolResponse = '{"error": "Failed to serialize tool_response"}';
}
// Queue observation
this.sessionManager.queueObservation(sessionDbId, {
tool_name,
tool_input: cleanedToolInput,
tool_response: cleanedToolResponse,
prompt_number: promptNumber,
cwd: cwd || ''
});
// Ensure SDK agent is running
this.ensureGeneratorRunning(sessionDbId, 'observation');
// Broadcast activity status
this.workerService.broadcastProcessingStatus();
// Broadcast SSE event
this.sseBroadcaster.broadcast({
type: 'observation_queued',
sessionDbId
});
res.json({ status: 'queued' });
} catch (error) {
logger.failure('WORKER', 'Observation by claudeId failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
} }
}
const store = this.dbManager.getSessionStore();
// Get or create session
const sessionDbId = store.createSDKSession(claudeSessionId, '', '');
const promptNumber = store.getPromptCounter(sessionDbId);
// Privacy check: skip if user prompt was entirely private
const userPrompt = store.getUserPrompt(claudeSessionId, promptNumber);
if (!userPrompt || userPrompt.trim() === '') {
logger.debug('HOOK', 'Skipping observation - user prompt was entirely private', {
sessionId: sessionDbId,
promptNumber,
tool_name
});
res.json({ status: 'skipped', reason: 'private' });
return;
}
// Strip memory tags from tool_input and tool_response
let cleanedToolInput = '{}';
let cleanedToolResponse = '{}';
try {
cleanedToolInput = tool_input !== undefined
? stripMemoryTagsFromJson(JSON.stringify(tool_input))
: '{}';
} catch (error) {
cleanedToolInput = '{"error": "Failed to serialize tool_input"}';
}
try {
cleanedToolResponse = tool_response !== undefined
? stripMemoryTagsFromJson(JSON.stringify(tool_response))
: '{}';
} catch (error) {
cleanedToolResponse = '{"error": "Failed to serialize tool_response"}';
}
// Queue observation
this.sessionManager.queueObservation(sessionDbId, {
tool_name,
tool_input: cleanedToolInput,
tool_response: cleanedToolResponse,
prompt_number: promptNumber,
cwd: cwd || ''
});
// Ensure SDK agent is running
this.ensureGeneratorRunning(sessionDbId, 'observation');
// Broadcast activity status
this.workerService.broadcastProcessingStatus();
// Broadcast SSE event
this.sseBroadcaster.broadcast({
type: 'observation_queued',
sessionDbId
});
res.json({ status: 'queued' });
});
/** /**
* Queue summarize by claudeSessionId (summary-hook uses this) * Queue summarize by claudeSessionId (summary-hook uses this)
@@ -390,47 +364,41 @@ export class SessionRoutes {
* *
* Checks privacy, queues summarize request for SDK agent * Checks privacy, queues summarize request for SDK agent
*/ */
private handleSummarizeByClaudeId(req: Request, res: Response): void { private handleSummarizeByClaudeId = this.wrapHandler((req: Request, res: Response): void => {
try { const { claudeSessionId, last_user_message, last_assistant_message } = req.body;
const { claudeSessionId, last_user_message, last_assistant_message } = req.body;
if (!claudeSessionId) { if (!claudeSessionId) {
res.status(400).json({ error: 'Missing claudeSessionId' }); return this.badRequest(res, 'Missing claudeSessionId');
return;
}
const store = this.dbManager.getSessionStore();
// Get or create session
const sessionDbId = store.createSDKSession(claudeSessionId, '', '');
const promptNumber = store.getPromptCounter(sessionDbId);
// Privacy check: skip if user prompt was entirely private
const userPrompt = store.getUserPrompt(claudeSessionId, promptNumber);
if (!userPrompt || userPrompt.trim() === '') {
logger.debug('HOOK', 'Skipping summary - user prompt was entirely private', {
sessionId: sessionDbId,
promptNumber
});
res.json({ status: 'skipped', reason: 'private' });
return;
}
// Queue summarize
this.sessionManager.queueSummarize(sessionDbId, last_user_message || '', last_assistant_message);
// Ensure SDK agent is running
this.ensureGeneratorRunning(sessionDbId, 'summarize');
// Broadcast activity status
this.workerService.broadcastProcessingStatus();
res.json({ status: 'queued' });
} catch (error) {
logger.failure('WORKER', 'Summarize by claudeId failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
} }
}
const store = this.dbManager.getSessionStore();
// Get or create session
const sessionDbId = store.createSDKSession(claudeSessionId, '', '');
const promptNumber = store.getPromptCounter(sessionDbId);
// Privacy check: skip if user prompt was entirely private
const userPrompt = store.getUserPrompt(claudeSessionId, promptNumber);
if (!userPrompt || userPrompt.trim() === '') {
logger.debug('HOOK', 'Skipping summary - user prompt was entirely private', {
sessionId: sessionDbId,
promptNumber
});
res.json({ status: 'skipped', reason: 'private' });
return;
}
// Queue summarize
this.sessionManager.queueSummarize(sessionDbId, last_user_message || '', last_assistant_message);
// Ensure SDK agent is running
this.ensureGeneratorRunning(sessionDbId, 'summarize');
// Broadcast activity status
this.workerService.broadcastProcessingStatus();
res.json({ status: 'queued' });
});
/** /**
* Complete session by claudeSessionId (cleanup-hook uses this) * Complete session by claudeSessionId (cleanup-hook uses this)
@@ -439,47 +407,41 @@ export class SessionRoutes {
* *
* Marks session complete, stops SDK agent, broadcasts status * Marks session complete, stops SDK agent, broadcasts status
*/ */
private async handleSessionCompleteByClaudeId(req: Request, res: Response): Promise<void> { private handleSessionCompleteByClaudeId = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
try { const { claudeSessionId } = req.body;
const { claudeSessionId } = req.body;
if (!claudeSessionId) { if (!claudeSessionId) {
res.status(400).json({ success: false, error: 'Missing claudeSessionId' }); return this.badRequest(res, 'Missing claudeSessionId');
return;
}
const store = this.dbManager.getSessionStore();
// Find session by claudeSessionId
const session = store.findActiveSDKSession(claudeSessionId);
if (!session) {
// No active session - nothing to clean up (may have already been completed)
res.json({ success: true, message: 'No active session found' });
return;
}
const sessionDbId = session.id;
// Delete from session manager (aborts SDK agent)
await this.sessionManager.deleteSession(sessionDbId);
// Mark session complete in database
this.dbManager.markSessionComplete(sessionDbId);
// Broadcast processing status
this.workerService.broadcastProcessingStatus();
// Broadcast SSE event
this.sseBroadcaster.broadcast({
type: 'session_completed',
timestamp: Date.now(),
sessionDbId
});
res.json({ success: true });
} catch (error) {
logger.failure('WORKER', 'Session complete by claudeId failed', {}, error as Error);
res.status(500).json({ success: false, error: String(error) });
} }
}
const store = this.dbManager.getSessionStore();
// Find session by claudeSessionId
const session = store.findActiveSDKSession(claudeSessionId);
if (!session) {
// No active session - nothing to clean up (may have already been completed)
res.json({ success: true, message: 'No active session found' });
return;
}
const sessionDbId = session.id;
// Delete from session manager (aborts SDK agent)
await this.sessionManager.deleteSession(sessionDbId);
// Mark session complete in database
this.dbManager.markSessionComplete(sessionDbId);
// Broadcast processing status
this.workerService.broadcastProcessingStatus();
// Broadcast SSE event
this.sseBroadcaster.broadcast({
type: 'session_completed',
timestamp: Date.now(),
sessionDbId
});
res.json({ success: true });
});
} }
+180 -212
View File
@@ -21,11 +21,14 @@ import {
ObservationType, ObservationType,
ObservationConcept ObservationConcept
} from '../../../../constants/observation-metadata.js'; } from '../../../../constants/observation-metadata.js';
import { BaseRouteHandler } from '../BaseRouteHandler.js';
export class SettingsRoutes { export class SettingsRoutes extends BaseRouteHandler {
constructor( constructor(
private settingsManager: SettingsManager private settingsManager: SettingsManager
) {} ) {
super();
}
setupRoutes(app: express.Application): void { setupRoutes(app: express.Application): void {
// Settings endpoints // Settings endpoints
@@ -45,263 +48,228 @@ export class SettingsRoutes {
/** /**
* Get environment settings (from ~/.claude/settings.json) * Get environment settings (from ~/.claude/settings.json)
*/ */
private handleGetSettings(req: Request, res: Response): void { private handleGetSettings = this.wrapHandler((req: Request, res: Response): void => {
try { const settingsPath = path.join(homedir(), '.claude-mem', 'settings.json');
const settingsPath = path.join(homedir(), '.claude-mem', 'settings.json');
if (!existsSync(settingsPath)) {
// Return defaults if file doesn't exist
res.json({
CLAUDE_MEM_MODEL: 'claude-haiku-4-5',
CLAUDE_MEM_CONTEXT_OBSERVATIONS: '50',
CLAUDE_MEM_WORKER_PORT: '37777',
// Token Economics
CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS: 'true',
CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS: 'true',
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT: 'true',
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT: 'true',
// Observation Filtering
CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES: DEFAULT_OBSERVATION_TYPES_STRING,
CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS: DEFAULT_OBSERVATION_CONCEPTS_STRING,
// Display Configuration
CLAUDE_MEM_CONTEXT_FULL_COUNT: '5',
CLAUDE_MEM_CONTEXT_FULL_FIELD: 'narrative',
CLAUDE_MEM_CONTEXT_SESSION_COUNT: '10',
// Feature Toggles
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: 'true',
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: 'false',
});
return;
}
const settingsData = readFileSync(settingsPath, 'utf-8');
const settings = JSON.parse(settingsData);
const env = settings.env || {};
if (!existsSync(settingsPath)) {
// Return defaults if file doesn't exist
res.json({ res.json({
CLAUDE_MEM_MODEL: env.CLAUDE_MEM_MODEL || 'claude-haiku-4-5', CLAUDE_MEM_MODEL: 'claude-haiku-4-5',
CLAUDE_MEM_CONTEXT_OBSERVATIONS: env.CLAUDE_MEM_CONTEXT_OBSERVATIONS || '50', CLAUDE_MEM_CONTEXT_OBSERVATIONS: '50',
CLAUDE_MEM_WORKER_PORT: env.CLAUDE_MEM_WORKER_PORT || '37777', CLAUDE_MEM_WORKER_PORT: '37777',
// Token Economics // Token Economics
CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS: env.CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS || 'true', CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS: 'true',
CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS: env.CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS || 'true', CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS: 'true',
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT: env.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT || 'true', CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT: 'true',
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT: env.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT || 'true', CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT: 'true',
// Observation Filtering // Observation Filtering
CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES: env.CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES || DEFAULT_OBSERVATION_TYPES_STRING, CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES: DEFAULT_OBSERVATION_TYPES_STRING,
CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS: env.CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS || DEFAULT_OBSERVATION_CONCEPTS_STRING, CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS: DEFAULT_OBSERVATION_CONCEPTS_STRING,
// Display Configuration // Display Configuration
CLAUDE_MEM_CONTEXT_FULL_COUNT: env.CLAUDE_MEM_CONTEXT_FULL_COUNT || '5', CLAUDE_MEM_CONTEXT_FULL_COUNT: '5',
CLAUDE_MEM_CONTEXT_FULL_FIELD: env.CLAUDE_MEM_CONTEXT_FULL_FIELD || 'narrative', CLAUDE_MEM_CONTEXT_FULL_FIELD: 'narrative',
CLAUDE_MEM_CONTEXT_SESSION_COUNT: env.CLAUDE_MEM_CONTEXT_SESSION_COUNT || '10', CLAUDE_MEM_CONTEXT_SESSION_COUNT: '10',
// Feature Toggles // Feature Toggles
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: env.CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY || 'true', CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: 'true',
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: env.CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE || 'false', CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: 'false',
}); });
} catch (error) { return;
logger.failure('WORKER', 'Get settings failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
} }
}
const settingsData = readFileSync(settingsPath, 'utf-8');
const settings = JSON.parse(settingsData);
const env = settings.env || {};
res.json({
CLAUDE_MEM_MODEL: env.CLAUDE_MEM_MODEL || 'claude-haiku-4-5',
CLAUDE_MEM_CONTEXT_OBSERVATIONS: env.CLAUDE_MEM_CONTEXT_OBSERVATIONS || '50',
CLAUDE_MEM_WORKER_PORT: env.CLAUDE_MEM_WORKER_PORT || '37777',
// Token Economics
CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS: env.CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS || 'true',
CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS: env.CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS || 'true',
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT: env.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT || 'true',
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT: env.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT || 'true',
// Observation Filtering
CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES: env.CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES || DEFAULT_OBSERVATION_TYPES_STRING,
CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS: env.CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS || DEFAULT_OBSERVATION_CONCEPTS_STRING,
// Display Configuration
CLAUDE_MEM_CONTEXT_FULL_COUNT: env.CLAUDE_MEM_CONTEXT_FULL_COUNT || '5',
CLAUDE_MEM_CONTEXT_FULL_FIELD: env.CLAUDE_MEM_CONTEXT_FULL_FIELD || 'narrative',
CLAUDE_MEM_CONTEXT_SESSION_COUNT: env.CLAUDE_MEM_CONTEXT_SESSION_COUNT || '10',
// Feature Toggles
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: env.CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY || 'true',
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: env.CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE || 'false',
});
});
/** /**
* Update environment settings (in ~/.claude/settings.json) with validation * Update environment settings (in ~/.claude/settings.json) with validation
*/ */
private handleUpdateSettings(req: Request, res: Response): void { private handleUpdateSettings = this.wrapHandler((req: Request, res: Response): void => {
try { // Validate CLAUDE_MEM_CONTEXT_OBSERVATIONS
// Validate CLAUDE_MEM_CONTEXT_OBSERVATIONS if (req.body.CLAUDE_MEM_CONTEXT_OBSERVATIONS) {
if (req.body.CLAUDE_MEM_CONTEXT_OBSERVATIONS) { const obsCount = parseInt(req.body.CLAUDE_MEM_CONTEXT_OBSERVATIONS, 10);
const obsCount = parseInt(req.body.CLAUDE_MEM_CONTEXT_OBSERVATIONS, 10); if (isNaN(obsCount) || obsCount < 1 || obsCount > 200) {
if (isNaN(obsCount) || obsCount < 1 || obsCount > 200) {
res.status(400).json({
success: false,
error: 'CLAUDE_MEM_CONTEXT_OBSERVATIONS must be between 1 and 200'
});
return;
}
}
// Validate CLAUDE_MEM_WORKER_PORT
if (req.body.CLAUDE_MEM_WORKER_PORT) {
const port = parseInt(req.body.CLAUDE_MEM_WORKER_PORT, 10);
if (isNaN(port) || port < 1024 || port > 65535) {
res.status(400).json({
success: false,
error: 'CLAUDE_MEM_WORKER_PORT must be between 1024 and 65535'
});
return;
}
}
// Validate context settings
const validation = this.validateContextSettings(req.body);
if (!validation.valid) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: validation.error error: 'CLAUDE_MEM_CONTEXT_OBSERVATIONS must be between 1 and 200'
}); });
return; return;
} }
// Read existing settings
const settingsPath = path.join(homedir(), '.claude-mem', 'settings.json');
let settings: any = { env: {} };
if (existsSync(settingsPath)) {
const settingsData = readFileSync(settingsPath, 'utf-8');
settings = JSON.parse(settingsData);
if (!settings.env) {
settings.env = {};
}
}
// Update all settings from request body
const settingKeys = [
'CLAUDE_MEM_MODEL',
'CLAUDE_MEM_CONTEXT_OBSERVATIONS',
'CLAUDE_MEM_WORKER_PORT',
'CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS',
'CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS',
'CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT',
'CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT',
'CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES',
'CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS',
'CLAUDE_MEM_CONTEXT_FULL_COUNT',
'CLAUDE_MEM_CONTEXT_FULL_FIELD',
'CLAUDE_MEM_CONTEXT_SESSION_COUNT',
'CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY',
'CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE',
];
for (const key of settingKeys) {
if (req.body[key] !== undefined) {
settings.env[key] = req.body[key];
}
}
// Write back
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
logger.info('WORKER', 'Settings updated');
res.json({ success: true, message: 'Settings updated successfully' });
} catch (error) {
logger.failure('WORKER', 'Update settings failed', {}, error as Error);
res.status(500).json({ success: false, error: String(error) });
} }
}
// Validate CLAUDE_MEM_WORKER_PORT
if (req.body.CLAUDE_MEM_WORKER_PORT) {
const port = parseInt(req.body.CLAUDE_MEM_WORKER_PORT, 10);
if (isNaN(port) || port < 1024 || port > 65535) {
res.status(400).json({
success: false,
error: 'CLAUDE_MEM_WORKER_PORT must be between 1024 and 65535'
});
return;
}
}
// Validate context settings
const validation = this.validateContextSettings(req.body);
if (!validation.valid) {
res.status(400).json({
success: false,
error: validation.error
});
return;
}
// Read existing settings
const settingsPath = path.join(homedir(), '.claude-mem', 'settings.json');
let settings: any = { env: {} };
if (existsSync(settingsPath)) {
const settingsData = readFileSync(settingsPath, 'utf-8');
settings = JSON.parse(settingsData);
if (!settings.env) {
settings.env = {};
}
}
// Update all settings from request body
const settingKeys = [
'CLAUDE_MEM_MODEL',
'CLAUDE_MEM_CONTEXT_OBSERVATIONS',
'CLAUDE_MEM_WORKER_PORT',
'CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS',
'CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS',
'CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT',
'CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT',
'CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES',
'CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS',
'CLAUDE_MEM_CONTEXT_FULL_COUNT',
'CLAUDE_MEM_CONTEXT_FULL_FIELD',
'CLAUDE_MEM_CONTEXT_SESSION_COUNT',
'CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY',
'CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE',
];
for (const key of settingKeys) {
if (req.body[key] !== undefined) {
settings.env[key] = req.body[key];
}
}
// Write back
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
logger.info('WORKER', 'Settings updated');
res.json({ success: true, message: 'Settings updated successfully' });
});
/** /**
* GET /api/mcp/status - Check if MCP search server is enabled * GET /api/mcp/status - Check if MCP search server is enabled
*/ */
private handleGetMcpStatus(req: Request, res: Response): void { private handleGetMcpStatus = this.wrapHandler((req: Request, res: Response): void => {
try { const enabled = this.isMcpEnabled();
const enabled = this.isMcpEnabled(); res.json({ enabled });
res.json({ enabled }); });
} catch (error) {
logger.failure('WORKER', 'Get MCP status failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/** /**
* POST /api/mcp/toggle - Toggle MCP search server on/off * POST /api/mcp/toggle - Toggle MCP search server on/off
* Body: { enabled: boolean } * Body: { enabled: boolean }
*/ */
private handleToggleMcp(req: Request, res: Response): void { private handleToggleMcp = this.wrapHandler((req: Request, res: Response): void => {
try { const { enabled } = req.body;
const { enabled } = req.body;
if (typeof enabled !== 'boolean') { if (typeof enabled !== 'boolean') {
res.status(400).json({ error: 'enabled must be a boolean' }); this.badRequest(res, 'enabled must be a boolean');
return; return;
}
this.toggleMcp(enabled);
res.json({ success: true, enabled: this.isMcpEnabled() });
} catch (error) {
logger.failure('WORKER', 'Toggle MCP failed', {}, error as Error);
res.status(500).json({ success: false, error: (error as Error).message });
} }
}
this.toggleMcp(enabled);
res.json({ success: true, enabled: this.isMcpEnabled() });
});
/** /**
* GET /api/branch/status - Get current branch information * GET /api/branch/status - Get current branch information
*/ */
private handleGetBranchStatus(req: Request, res: Response): void { private handleGetBranchStatus = this.wrapHandler((req: Request, res: Response): void => {
try { const info = getBranchInfo();
const info = getBranchInfo(); res.json(info);
res.json(info); });
} catch (error) {
logger.failure('WORKER', 'Failed to get branch status', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/** /**
* POST /api/branch/switch - Switch to a different branch * POST /api/branch/switch - Switch to a different branch
* Body: { branch: "main" | "beta/7.0" } * Body: { branch: "main" | "beta/7.0" }
*/ */
private async handleSwitchBranch(req: Request, res: Response): Promise<void> { private handleSwitchBranch = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
try { const { branch } = req.body;
const { branch } = req.body;
if (!branch) { if (!branch) {
res.status(400).json({ success: false, error: 'Missing branch parameter' }); res.status(400).json({ success: false, error: 'Missing branch parameter' });
return; return;
}
// Validate branch name
const allowedBranches = ['main', 'beta/7.0'];
if (!allowedBranches.includes(branch)) {
res.status(400).json({
success: false,
error: `Invalid branch. Allowed: ${allowedBranches.join(', ')}`
});
return;
}
logger.info('WORKER', 'Branch switch requested', { branch });
const result = await switchBranch(branch);
if (result.success) {
// Schedule worker restart after response is sent
setTimeout(() => {
logger.info('WORKER', 'Restarting worker after branch switch');
process.exit(0); // PM2 will restart the worker
}, 1000);
}
res.json(result);
} catch (error) {
logger.failure('WORKER', 'Branch switch failed', {}, error as Error);
res.status(500).json({ success: false, error: (error as Error).message });
} }
}
// Validate branch name
const allowedBranches = ['main', 'beta/7.0'];
if (!allowedBranches.includes(branch)) {
res.status(400).json({
success: false,
error: `Invalid branch. Allowed: ${allowedBranches.join(', ')}`
});
return;
}
logger.info('WORKER', 'Branch switch requested', { branch });
const result = await switchBranch(branch);
if (result.success) {
// Schedule worker restart after response is sent
setTimeout(() => {
logger.info('WORKER', 'Restarting worker after branch switch');
process.exit(0); // PM2 will restart the worker
}, 1000);
}
res.json(result);
});
/** /**
* POST /api/branch/update - Pull latest updates for current branch * POST /api/branch/update - Pull latest updates for current branch
*/ */
private async handleUpdateBranch(req: Request, res: Response): Promise<void> { private handleUpdateBranch = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
try { logger.info('WORKER', 'Branch update requested');
logger.info('WORKER', 'Branch update requested');
const result = await pullUpdates(); const result = await pullUpdates();
if (result.success) { if (result.success) {
// Schedule worker restart after response is sent // Schedule worker restart after response is sent
setTimeout(() => { setTimeout(() => {
logger.info('WORKER', 'Restarting worker after branch update'); logger.info('WORKER', 'Restarting worker after branch update');
process.exit(0); // PM2 will restart the worker process.exit(0); // PM2 will restart the worker
}, 1000); }, 1000);
}
res.json(result);
} catch (error) {
logger.failure('WORKER', 'Branch update failed', {}, error as Error);
res.status(500).json({ success: false, error: (error as Error).message });
} }
}
res.json(result);
});
/** /**
* Validate context settings from request body * Validate context settings from request body
+16 -19
View File
@@ -9,17 +9,19 @@ import express, { Request, Response } from 'express';
import path from 'path'; import path from 'path';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { getPackageRoot } from '../../../../shared/paths.js'; import { getPackageRoot } from '../../../../shared/paths.js';
import { logger } from '../../../../utils/logger.js';
import { SSEBroadcaster } from '../../SSEBroadcaster.js'; import { SSEBroadcaster } from '../../SSEBroadcaster.js';
import { DatabaseManager } from '../../DatabaseManager.js'; import { DatabaseManager } from '../../DatabaseManager.js';
import { SessionManager } from '../../SessionManager.js'; import { SessionManager } from '../../SessionManager.js';
import { BaseRouteHandler } from '../BaseRouteHandler.js';
export class ViewerRoutes { export class ViewerRoutes extends BaseRouteHandler {
constructor( constructor(
private sseBroadcaster: SSEBroadcaster, private sseBroadcaster: SSEBroadcaster,
private dbManager: DatabaseManager, private dbManager: DatabaseManager,
private sessionManager: SessionManager private sessionManager: SessionManager
) {} ) {
super();
}
setupRoutes(app: express.Application): void { setupRoutes(app: express.Application): void {
app.get('/health', this.handleHealth.bind(this)); app.get('/health', this.handleHealth.bind(this));
@@ -30,30 +32,25 @@ export class ViewerRoutes {
/** /**
* Health check endpoint * Health check endpoint
*/ */
private handleHealth(req: Request, res: Response): void { private handleHealth = this.wrapHandler((req: Request, res: Response): void => {
res.json({ status: 'ok', timestamp: Date.now() }); res.json({ status: 'ok', timestamp: Date.now() });
} });
/** /**
* Serve viewer UI * Serve viewer UI
*/ */
private handleViewerUI(req: Request, res: Response): void { private handleViewerUI = this.wrapHandler((req: Request, res: Response): void => {
try { const packageRoot = getPackageRoot();
const packageRoot = getPackageRoot(); const viewerPath = path.join(packageRoot, 'plugin', 'ui', 'viewer.html');
const viewerPath = path.join(packageRoot, 'plugin', 'ui', 'viewer.html'); const html = readFileSync(viewerPath, 'utf-8');
const html = readFileSync(viewerPath, 'utf-8'); res.setHeader('Content-Type', 'text/html');
res.setHeader('Content-Type', 'text/html'); res.send(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 * SSE stream endpoint
*/ */
private handleSSEStream(req: Request, res: Response): void { private handleSSEStream = this.wrapHandler((req: Request, res: Response): void => {
// Setup SSE headers // Setup SSE headers
res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Cache-Control', 'no-cache');
@@ -78,5 +75,5 @@ export class ViewerRoutes {
isProcessing, isProcessing,
queueDepth queueDepth
}); });
} });
} }