Fix 404 error on /api/context/inject during worker startup (#310)
* Initial plan * Fix worker service connection failed error by adding early context/inject route Co-authored-by: thedotmack <683968+thedotmack@users.noreply.github.com> * Add integration test for context inject early access Co-authored-by: thedotmack <683968+thedotmack@users.noreply.github.com> * Fix import path and improve test code style Co-authored-by: thedotmack <683968+thedotmack@users.noreply.github.com> * Add clarifying comment about intentional code duplication Co-authored-by: thedotmack <683968+thedotmack@users.noreply.github.com> * build: compile fix for /api/context/inject 404 error Compiled worker service and MCP server with the initialization race condition fix. Validation results: All tests passing, route available immediately on restart. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: thedotmack <683968+thedotmack@users.noreply.github.com> Co-authored-by: Alex Newman <thedotmack@gmail.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
+100
-26
@@ -60,9 +60,18 @@ export class WorkerService {
|
|||||||
private searchRoutes: SearchRoutes | null;
|
private searchRoutes: SearchRoutes | null;
|
||||||
private settingsRoutes: SettingsRoutes;
|
private settingsRoutes: SettingsRoutes;
|
||||||
|
|
||||||
|
// Initialization tracking
|
||||||
|
private initializationComplete: Promise<void>;
|
||||||
|
private resolveInitialization!: () => void;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.app = express();
|
this.app = express();
|
||||||
|
|
||||||
|
// Initialize the promise that will resolve when background initialization completes
|
||||||
|
this.initializationComplete = new Promise((resolve) => {
|
||||||
|
this.resolveInitialization = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize domain services
|
// Initialize domain services
|
||||||
this.dbManager = new DatabaseManager();
|
this.dbManager = new DatabaseManager();
|
||||||
this.sessionManager = new SessionManager(this.dbManager);
|
this.sessionManager = new SessionManager(this.dbManager);
|
||||||
@@ -155,6 +164,60 @@ export class WorkerService {
|
|||||||
this.dataRoutes.setupRoutes(this.app);
|
this.dataRoutes.setupRoutes(this.app);
|
||||||
// searchRoutes is set up after database initialization in initializeBackground()
|
// searchRoutes is set up after database initialization in initializeBackground()
|
||||||
this.settingsRoutes.setupRoutes(this.app);
|
this.settingsRoutes.setupRoutes(this.app);
|
||||||
|
|
||||||
|
// Register early handler for /api/context/inject to avoid 404 during startup
|
||||||
|
// This handler waits for initialization to complete before delegating to SearchRoutes
|
||||||
|
// NOTE: This duplicates logic from SearchRoutes.handleContextInject by design,
|
||||||
|
// as we need the route available immediately before SearchRoutes is initialized
|
||||||
|
this.app.get('/api/context/inject', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// Wait for initialization to complete (with timeout)
|
||||||
|
const timeoutMs = 30000; // 30 second timeout
|
||||||
|
const timeoutPromise = new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('Initialization timeout')), timeoutMs)
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.race([this.initializationComplete, timeoutPromise]);
|
||||||
|
|
||||||
|
// If searchRoutes is still null after initialization, something went wrong
|
||||||
|
if (!this.searchRoutes) {
|
||||||
|
res.status(503).json({ error: 'Search routes not initialized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegate to the proper handler by re-processing the request
|
||||||
|
// Since we're already in the middleware chain, we need to call the handler directly
|
||||||
|
const projectName = req.query.project as string;
|
||||||
|
const useColors = req.query.colors === 'true';
|
||||||
|
|
||||||
|
if (!projectName) {
|
||||||
|
res.status(400).json({ error: 'Project parameter is required' });
|
||||||
|
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.error('WORKER', 'Context inject handler failed', {}, error as Error);
|
||||||
|
res.status(500).json({ error: error instanceof Error ? error.message : 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -228,36 +291,47 @@ export class WorkerService {
|
|||||||
* Background initialization - runs after HTTP server is listening
|
* Background initialization - runs after HTTP server is listening
|
||||||
*/
|
*/
|
||||||
private async initializeBackground(): Promise<void> {
|
private async initializeBackground(): Promise<void> {
|
||||||
// Clean up any orphaned chroma-mcp processes BEFORE starting our own
|
try {
|
||||||
await this.cleanupOrphanedProcesses();
|
// Clean up any orphaned chroma-mcp processes BEFORE starting our own
|
||||||
|
await this.cleanupOrphanedProcesses();
|
||||||
|
|
||||||
// Initialize database (once, stays open)
|
// Initialize database (once, stays open)
|
||||||
await this.dbManager.initialize();
|
await this.dbManager.initialize();
|
||||||
|
|
||||||
// Initialize search services (requires initialized database)
|
// Initialize search services (requires initialized database)
|
||||||
const formattingService = new FormattingService();
|
const formattingService = new FormattingService();
|
||||||
const timelineService = new TimelineService();
|
const timelineService = new TimelineService();
|
||||||
const searchManager = new SearchManager(
|
const searchManager = new SearchManager(
|
||||||
this.dbManager.getSessionSearch(),
|
this.dbManager.getSessionSearch(),
|
||||||
this.dbManager.getSessionStore(),
|
this.dbManager.getSessionStore(),
|
||||||
this.dbManager.getChromaSync(),
|
this.dbManager.getChromaSync(),
|
||||||
formattingService,
|
formattingService,
|
||||||
timelineService
|
timelineService
|
||||||
);
|
);
|
||||||
this.searchRoutes = new SearchRoutes(searchManager);
|
this.searchRoutes = new SearchRoutes(searchManager);
|
||||||
this.searchRoutes.setupRoutes(this.app); // Setup search routes now that SearchManager is ready
|
this.searchRoutes.setupRoutes(this.app); // Setup search routes now that SearchManager is ready
|
||||||
logger.info('WORKER', 'SearchManager initialized and search routes registered');
|
logger.info('WORKER', 'SearchManager initialized and search routes registered');
|
||||||
|
|
||||||
// Connect to MCP server
|
// Connect to MCP server
|
||||||
const mcpServerPath = path.join(__dirname, 'mcp-server.cjs');
|
const mcpServerPath = path.join(__dirname, 'mcp-server.cjs');
|
||||||
const transport = new StdioClientTransport({
|
const transport = new StdioClientTransport({
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: [mcpServerPath],
|
args: [mcpServerPath],
|
||||||
env: process.env
|
env: process.env
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.mcpClient.connect(transport);
|
await this.mcpClient.connect(transport);
|
||||||
logger.success('WORKER', 'Connected to MCP server');
|
logger.success('WORKER', 'Connected to MCP server');
|
||||||
|
|
||||||
|
// Signal that initialization is complete
|
||||||
|
this.resolveInitialization();
|
||||||
|
logger.info('SYSTEM', 'Background initialization complete');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('SYSTEM', 'Background initialization failed', {}, error as Error);
|
||||||
|
// Still resolve to prevent hanging requests, but they'll see searchRoutes is null
|
||||||
|
this.resolveInitialization();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Integration Test: Context Inject Early Access
|
||||||
|
*
|
||||||
|
* Tests that /api/context/inject endpoint is available immediately
|
||||||
|
* when worker starts, even before background initialization completes.
|
||||||
|
*
|
||||||
|
* This prevents the 404 error described in the issue where the hook
|
||||||
|
* tries to access the endpoint before SearchRoutes are registered.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
describe('Context Inject Early Access', () => {
|
||||||
|
const workerPath = path.join(__dirname, '../../plugin/scripts/worker-service.cjs');
|
||||||
|
|
||||||
|
it('should have /api/context/inject route available immediately on startup', async () => {
|
||||||
|
// This test verifies the fix by checking that:
|
||||||
|
// 1. The route exists immediately (no 404)
|
||||||
|
// 2. The route waits for initialization before processing
|
||||||
|
// 3. Requests don't fail with "Cannot GET /api/context/inject"
|
||||||
|
|
||||||
|
// The fix adds an early handler that:
|
||||||
|
// - Registers the route in setupRoutes() (called during construction)
|
||||||
|
// - Waits for initializationComplete promise
|
||||||
|
// - Processes the request after initialization
|
||||||
|
|
||||||
|
// Since we can't easily spin up a full worker in tests,
|
||||||
|
// we verify the code structure is correct by checking
|
||||||
|
// the compiled output contains the necessary pieces
|
||||||
|
|
||||||
|
const workerCode = fs.readFileSync(workerPath, 'utf-8');
|
||||||
|
|
||||||
|
// Verify initialization promise exists
|
||||||
|
expect(workerCode).toContain('initializationComplete');
|
||||||
|
expect(workerCode).toContain('resolveInitialization');
|
||||||
|
|
||||||
|
// Verify early route handler is registered in setupRoutes
|
||||||
|
expect(workerCode).toContain('/api/context/inject');
|
||||||
|
expect(workerCode).toContain('Promise.race');
|
||||||
|
|
||||||
|
// Verify the promise is resolved after initialization
|
||||||
|
expect(workerCode).toContain('this.resolveInitialization()');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle timeout if initialization takes too long', () => {
|
||||||
|
const workerCode = fs.readFileSync(workerPath, 'utf-8');
|
||||||
|
|
||||||
|
// Verify timeout protection (30 seconds)
|
||||||
|
expect(workerCode).toContain('3e4'); // 30000 in scientific notation
|
||||||
|
expect(workerCode).toContain('Initialization timeout');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user