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

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