refactor: replace primary/secondary engine pattern with uniform ProjectEngineManager
Remove the anti-pattern where the cwd project was treated as "primary" with a special engine, and other projects got "secondary" engines through a separate code path. Every project now gets an identical ProjectEngine created through ProjectEngineManager. Key changes: - Add ProjectEngineManager class to @fusion/engine for uniform engine lifecycle - Replace manual engine maps in dashboard.ts and serve.ts with engineManager - Add engineManager to ServerOptions for per-project engine resolution - Add getProjectContext() helper in routes.ts (replaces 199 getScopedStore calls) - Merge and automation routes now resolve engine subsystems per-request - SSE endpoint uses engine's store when available (same EventEmitter) - Fix tsx not found in dev-with-memory.mjs startup script - Add invalidateAllGlobalSettingsCaches for cross-project settings sync Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -221,6 +221,7 @@ export function createMissionRouter(
|
||||
recoverActiveMissions(): Promise<{ recoveredCount: number }>;
|
||||
isRunning(): boolean;
|
||||
},
|
||||
engineManager?: import("@fusion/engine").ProjectEngineManager,
|
||||
): Router {
|
||||
const router = Router();
|
||||
const requestContext = new AsyncLocalStorage<ReturnType<TaskStore["getMissionStore"]>>();
|
||||
@@ -351,6 +352,23 @@ export function createMissionRouter(
|
||||
return projectId ? await getOrCreateProjectStore(projectId) : store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to resolve project context for the current request.
|
||||
* When engineManager is available and the request targets a known project,
|
||||
* returns the engine's TaskStore so callers share the same in-memory state.
|
||||
*/
|
||||
async function getProjectContext(req: Request) {
|
||||
const projectId = getProjectIdFromRequest(req);
|
||||
if (projectId && engineManager) {
|
||||
const engine = engineManager.getEngine(projectId);
|
||||
if (engine) {
|
||||
return { store: engine.getTaskStore(), engine, projectId };
|
||||
}
|
||||
}
|
||||
const scopedStore = await getScopedStoreForRequest(req);
|
||||
return { store: scopedStore, engine: undefined, projectId };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/missions/interview/start
|
||||
* Start a mission interview session with AI agent streaming.
|
||||
@@ -385,7 +403,7 @@ export function createMissionRouter(
|
||||
|
||||
try {
|
||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||
const scopedStore = await getScopedStoreForRequest(req);
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const settings = await scopedStore.getSettings();
|
||||
|
||||
@@ -442,7 +460,7 @@ export function createMissionRouter(
|
||||
}
|
||||
|
||||
try {
|
||||
const scopedStore = await getScopedStoreForRequest(req);
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const settings = await scopedStore.getSettings();
|
||||
|
||||
@@ -497,7 +515,7 @@ export function createMissionRouter(
|
||||
}
|
||||
|
||||
try {
|
||||
const scopedStore = await getScopedStoreForRequest(req);
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const settings = await scopedStore.getSettings();
|
||||
|
||||
|
||||
@@ -133,3 +133,18 @@ export function evictAllProjectStores(): void {
|
||||
evictProjectStore(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the global settings cache in all cached project stores.
|
||||
*
|
||||
* Each project-specific TaskStore holds its own GlobalSettingsStore with an
|
||||
* in-memory cache. When global settings are updated via the main store (e.g.,
|
||||
* PUT /settings/global), the file on disk is updated but the per-project
|
||||
* caches remain stale. Calling this function forces the next getSettings()
|
||||
* call in each project store to re-read from disk.
|
||||
*/
|
||||
export function invalidateAllGlobalSettingsCaches(): void {
|
||||
for (const store of storeCache.values()) {
|
||||
store.getGlobalSettingsStore().invalidateCache();
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,8 +68,12 @@ process.on("beforeExit", () => {
|
||||
export interface ServerOptions {
|
||||
/** Optional ProjectEngine — when provided, subsystems (onMerge, automationStore,
|
||||
* missionAutopilot, missionExecutionLoop, heartbeatMonitor) are derived from it.
|
||||
* Explicit options still override engine-derived values. */
|
||||
* Explicit options still override engine-derived values.
|
||||
* @deprecated Use engineManager instead for multi-project support. */
|
||||
engine?: import("@fusion/engine").ProjectEngine;
|
||||
/** ProjectEngineManager for uniform multi-project engine lifecycle.
|
||||
* When provided, the server can resolve per-project engines for route handlers. */
|
||||
engineManager?: import("@fusion/engine").ProjectEngineManager;
|
||||
/** Custom merge handler — when provided, used instead of store.mergeTask */
|
||||
onMerge?: (taskId: string) => Promise<MergeResult>;
|
||||
/** When true, run API/websocket server only (skip frontend static assets + SPA fallback) */
|
||||
@@ -281,15 +285,24 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
// Rate limiting — stricter limit on SSE connections
|
||||
app.get("/api/events", rateLimit(RATE_LIMITS.sse), async (req, res) => {
|
||||
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;
|
||||
const engineManager = options?.engineManager;
|
||||
|
||||
if (!projectId) {
|
||||
createSSE(store, store.getMissionStore(), aiSessionStore, store.getPluginStore())(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the shared project-store resolver so SSE listeners attach to
|
||||
// the same EventEmitter used by project-scoped task API routes.
|
||||
const scopedStore = await getOrCreateProjectStore(projectId);
|
||||
// Prefer the engine's store when available — this ensures SSE listeners
|
||||
// attach to the same EventEmitter instance that the engine writes to,
|
||||
// rather than a separate store created by getOrCreateProjectStore.
|
||||
let scopedStore: TaskStore;
|
||||
if (engineManager) {
|
||||
const engine = engineManager.getEngine(projectId);
|
||||
scopedStore = engine?.getTaskStore() ?? await getOrCreateProjectStore(projectId);
|
||||
} else {
|
||||
scopedStore = await getOrCreateProjectStore(projectId);
|
||||
}
|
||||
createSSE(scopedStore, scopedStore.getMissionStore(), aiSessionStore, scopedStore.getPluginStore(), {
|
||||
projectId,
|
||||
})(req, res);
|
||||
|
||||
Reference in New Issue
Block a user