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:
gsxdsm
2026-04-13 13:32:38 -07:00
parent 6d414f7b59
commit 1383b8d7d6
12 changed files with 520 additions and 500 deletions

View File

@@ -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();

View File

@@ -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

View File

@@ -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);