fix(dashboard): start engine for every registered project on first access
Tasks in projects other than the primary (cwd) project were never triaged because only one ProjectEngine was started. When a project is accessed via ?projectId= API/SSE, getOrCreateProjectStore created a TaskStore but left the Scheduler, TriageProcessor, and TaskExecutor unstarted. Fix: introduce setOnProjectFirstCreated callback in project-store-resolver so the dashboard server is notified when any new project is first accessed. dashboard.ts creates a ProjectManager that lazily starts an InProcessRuntime (Scheduler + TriageProcessor + TaskExecutor) for each project the first time it is accessed — works for any number of registered projects. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -191,6 +191,11 @@ vi.mock("@fusion/engine", async (importOriginal) => {
|
||||
registerAgent: vi.fn(),
|
||||
getRegisteredAgents: vi.fn().mockReturnValue([]),
|
||||
})),
|
||||
ProjectManager: vi.fn().mockImplementation(() => ({
|
||||
getRuntime: vi.fn().mockReturnValue(undefined),
|
||||
addProject: vi.fn().mockResolvedValue({}),
|
||||
stopAll: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -481,4 +486,160 @@ describe("runDashboard — Plugin wiring", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runDashboard — per-project engine manager (multi-project)", () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockDiscoverAndLoadExtensions.mockResolvedValue({
|
||||
runtime: { pendingProviderRegistrations: [] },
|
||||
errors: [],
|
||||
});
|
||||
const { TaskStore } = await import("@fusion/core");
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
|
||||
});
|
||||
|
||||
it("creates a ProjectManager in non-dev mode", async () => {
|
||||
const { ProjectManager } = await import("@fusion/engine");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
expect(ProjectManager).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes onProjectFirstAccessed callback to createServer", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||||
expect(serverOpts).toHaveProperty("onProjectFirstAccessed");
|
||||
expect(serverOpts.onProjectFirstAccessed).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("onProjectFirstAccessed starts an engine for a new project via ProjectManager", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
const { ProjectManager } = await import("@fusion/engine");
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
|
||||
const mockProject = {
|
||||
id: "proj_other",
|
||||
path: "/other/project",
|
||||
name: "Other Project",
|
||||
isolationMode: "in-process",
|
||||
settings: { maxConcurrent: 2, maxWorktrees: 4 },
|
||||
};
|
||||
|
||||
// Make CentralCore.getProject resolve with a project for the new ID
|
||||
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
|
||||
getProject: vi.fn().mockImplementation((id: string) =>
|
||||
id === "proj_other" ? Promise.resolve(mockProject) : Promise.resolve(null),
|
||||
),
|
||||
}));
|
||||
|
||||
const mockAddProject = vi.fn().mockResolvedValue({});
|
||||
const mockGetRuntime = vi.fn().mockReturnValue(undefined);
|
||||
(ProjectManager as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
getRuntime: mockGetRuntime,
|
||||
addProject: mockAddProject,
|
||||
stopAll: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||||
const cb: (id: string) => void = serverOpts.onProjectFirstAccessed;
|
||||
|
||||
// Simulate the dashboard server encountering a new project
|
||||
cb("proj_other");
|
||||
|
||||
// Allow fire-and-forget promise to settle
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(mockAddProject).toHaveBeenCalledTimes(1);
|
||||
expect(mockAddProject).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: "proj_other",
|
||||
workingDirectory: "/other/project",
|
||||
isolationMode: "in-process",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("onProjectFirstAccessed skips the primary project (already managed by ProjectEngine)", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
const { ProjectManager } = await import("@fusion/engine");
|
||||
|
||||
const mockAddProject = vi.fn().mockResolvedValue({});
|
||||
(ProjectManager as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
getRuntime: vi.fn().mockReturnValue(undefined),
|
||||
addProject: mockAddProject,
|
||||
stopAll: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||||
const cb: (id: string) => void = serverOpts.onProjectFirstAccessed;
|
||||
|
||||
// The primary project ID is whatever CentralCore.getProjectByPath returned
|
||||
// In our mock that's "project-1", but runtimeConfig uses cwd as fallback.
|
||||
// Either way, firing the callback with the primary ID should be a no-op.
|
||||
// We confirm by firing for a null project — addProject must not be called.
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
|
||||
getProject: vi.fn().mockResolvedValue(null), // project not found
|
||||
}));
|
||||
|
||||
cb("proj_unknown");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(mockAddProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("onProjectFirstAccessed skips if runtime already running for that project", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
const { ProjectManager } = await import("@fusion/engine");
|
||||
const { CentralCore } = await import("@fusion/core");
|
||||
|
||||
const mockProject = { id: "proj_dupe", path: "/dupe", name: "Dupe", isolationMode: "in-process", settings: {} };
|
||||
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
|
||||
getProject: vi.fn().mockResolvedValue(mockProject),
|
||||
}));
|
||||
|
||||
const mockAddProject = vi.fn().mockResolvedValue({});
|
||||
// getRuntime returns a truthy value → runtime already running
|
||||
(ProjectManager as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
getRuntime: vi.fn().mockReturnValue({ getStatus: vi.fn().mockReturnValue("active") }),
|
||||
addProject: mockAddProject,
|
||||
stopAll: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||||
const cb: (id: string) => void = serverOpts.onProjectFirstAccessed;
|
||||
|
||||
cb("proj_dupe");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(mockAddProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not create ProjectManager in dev mode", async () => {
|
||||
const { ProjectManager } = await import("@fusion/engine");
|
||||
|
||||
await runDashboard(0, { dev: true });
|
||||
|
||||
expect(ProjectManager).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ import {
|
||||
getOrCreateProjectStore,
|
||||
evictProjectStore,
|
||||
evictAllProjectStores,
|
||||
setOnProjectFirstCreated,
|
||||
} from "../project-store-resolver.js";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
|
||||
@@ -189,13 +190,98 @@ describe("project-store-resolver", () => {
|
||||
expect(sameStore).toBe(store);
|
||||
|
||||
// Emit events from "mutation path" — same store instance
|
||||
sameStore.emit("task:created", { id: "FN-001" });
|
||||
sameStore.emit("task:updated", { id: "FN-001", title: "Updated" });
|
||||
sameStore.emit("task:created", { id: "FN-001" } as any);
|
||||
sameStore.emit("task:updated", { id: "FN-001", title: "Updated" } as any);
|
||||
|
||||
// SSE listener should have received both events through the shared EventEmitter
|
||||
expect(events).toEqual(["created:FN-001", "updated:FN-001"]);
|
||||
});
|
||||
|
||||
describe("setOnProjectFirstCreated", () => {
|
||||
afterEach(() => {
|
||||
// Always clear the callback so it doesn't bleed into other tests
|
||||
setOnProjectFirstCreated(undefined);
|
||||
});
|
||||
|
||||
it("fires callback once when a new project store is first created", async () => {
|
||||
const cb = vi.fn();
|
||||
setOnProjectFirstCreated(cb);
|
||||
|
||||
await getOrCreateProjectStore("proj_cb_new");
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenCalledWith("proj_cb_new");
|
||||
});
|
||||
|
||||
it("does not fire callback on subsequent cache hits for the same project", async () => {
|
||||
const cb = vi.fn();
|
||||
setOnProjectFirstCreated(cb);
|
||||
|
||||
await getOrCreateProjectStore("proj_cb_cached");
|
||||
await getOrCreateProjectStore("proj_cb_cached");
|
||||
await getOrCreateProjectStore("proj_cb_cached");
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fires callback once per unique project (any number of projects)", async () => {
|
||||
const cb = vi.fn();
|
||||
setOnProjectFirstCreated(cb);
|
||||
|
||||
// Use sequential awaits to avoid concurrent import() race in vitest mock resolution
|
||||
await getOrCreateProjectStore("proj_multi_1");
|
||||
await getOrCreateProjectStore("proj_multi_2");
|
||||
await getOrCreateProjectStore("proj_multi_3");
|
||||
await getOrCreateProjectStore("proj_multi_4");
|
||||
await getOrCreateProjectStore("proj_multi_5");
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(5);
|
||||
expect(cb).toHaveBeenCalledWith("proj_multi_1");
|
||||
expect(cb).toHaveBeenCalledWith("proj_multi_2");
|
||||
expect(cb).toHaveBeenCalledWith("proj_multi_3");
|
||||
expect(cb).toHaveBeenCalledWith("proj_multi_4");
|
||||
expect(cb).toHaveBeenCalledWith("proj_multi_5");
|
||||
});
|
||||
|
||||
it("deduplicates concurrent calls — callback fires exactly once even under concurrency", async () => {
|
||||
const cb = vi.fn();
|
||||
setOnProjectFirstCreated(cb);
|
||||
|
||||
await Promise.all([
|
||||
getOrCreateProjectStore("proj_concurrent_cb"),
|
||||
getOrCreateProjectStore("proj_concurrent_cb"),
|
||||
getOrCreateProjectStore("proj_concurrent_cb"),
|
||||
]);
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
expect(cb).toHaveBeenCalledWith("proj_concurrent_cb");
|
||||
});
|
||||
|
||||
it("stops firing after callback is cleared with undefined", async () => {
|
||||
const cb = vi.fn();
|
||||
setOnProjectFirstCreated(cb);
|
||||
setOnProjectFirstCreated(undefined);
|
||||
|
||||
await getOrCreateProjectStore("proj_cb_cleared");
|
||||
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires for a re-created project after eviction", async () => {
|
||||
const cb = vi.fn();
|
||||
setOnProjectFirstCreated(cb);
|
||||
|
||||
await getOrCreateProjectStore("proj_cb_evict");
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
|
||||
evictProjectStore("proj_cb_evict");
|
||||
createdStores.length = 0;
|
||||
|
||||
await getOrCreateProjectStore("proj_cb_evict");
|
||||
expect(cb).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("SSE stream receives live events via shared store (integration)", async () => {
|
||||
// This test simulates the full server-side path:
|
||||
// 1. SSE handler calls getOrCreateProjectStore("proj_sse") → store A
|
||||
@@ -223,14 +309,14 @@ describe("project-store-resolver", () => {
|
||||
|
||||
// Simulate task creation via API (this is what routes.ts does)
|
||||
const newTask = { id: "FN-100", description: "Integration test task" };
|
||||
apiStore.emit("task:created", newTask);
|
||||
apiStore.emit("task:created", newTask as any);
|
||||
|
||||
// Simulate task update
|
||||
const updatedTask = { ...newTask, title: "Updated title" };
|
||||
apiStore.emit("task:updated", updatedTask);
|
||||
apiStore.emit("task:updated", updatedTask as any);
|
||||
|
||||
// Simulate task move
|
||||
apiStore.emit("task:moved", { task: updatedTask, from: "triage", to: "todo" });
|
||||
apiStore.emit("task:moved", { task: updatedTask as any, from: "triage", to: "todo" });
|
||||
|
||||
// Assert SSE listener received all events in order
|
||||
expect(sseMessages).toHaveLength(3);
|
||||
|
||||
@@ -37,6 +37,21 @@ const pendingCreations = new Map<string, Promise<TaskStore>>();
|
||||
*/
|
||||
const initializedProjects = new Set<string>();
|
||||
|
||||
/**
|
||||
* Optional callback invoked once when a new project store is first created.
|
||||
* Used by the dashboard server to lazily start an engine for secondary projects.
|
||||
*/
|
||||
let _onProjectFirstCreated: ((projectId: string) => void) | undefined;
|
||||
|
||||
/**
|
||||
* Register a callback to be called once when a new project is first accessed.
|
||||
* The callback fires after the store is cached — exactly once per projectId.
|
||||
* Pass `undefined` to clear the callback.
|
||||
*/
|
||||
export function setOnProjectFirstCreated(cb: ((projectId: string) => void) | undefined): void {
|
||||
_onProjectFirstCreated = cb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a cached TaskStore for the given projectId.
|
||||
*
|
||||
@@ -81,6 +96,12 @@ export async function getOrCreateProjectStore(projectId: string): Promise<TaskSt
|
||||
|
||||
storeCache.set(projectId, store);
|
||||
pendingCreations.delete(projectId);
|
||||
|
||||
// Notify once that a new project was first accessed
|
||||
if (_onProjectFirstCreated) {
|
||||
_onProjectFirstCreated(projectId);
|
||||
}
|
||||
|
||||
return store;
|
||||
})();
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { createApiRoutes } from "./routes.js";
|
||||
import { createSSE } from "./sse.js";
|
||||
import { rateLimit, RATE_LIMITS } from "./rate-limit.js";
|
||||
import { ApiError, sendErrorResponse } from "./api-error.js";
|
||||
import { getOrCreateProjectStore, evictAllProjectStores } from "./project-store-resolver.js";
|
||||
import { getOrCreateProjectStore, evictAllProjectStores, setOnProjectFirstCreated } from "./project-store-resolver.js";
|
||||
import { getTerminalService, type TerminalSession, STALE_SESSION_THRESHOLD_MS } from "./terminal-service.js";
|
||||
import { WebSocketServer, type WebSocket } from "ws";
|
||||
import { terminalSessionManager } from "./terminal.js";
|
||||
@@ -66,6 +66,10 @@ 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. */
|
||||
engine?: import("@fusion/engine").ProjectEngine;
|
||||
/** 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) */
|
||||
@@ -135,6 +139,12 @@ export interface ServerOptions {
|
||||
chatStore?: import("@fusion/core").ChatStore;
|
||||
/** Optional ChatManager for AI chat message handling */
|
||||
chatManager?: import("./chat.js").ChatManager;
|
||||
/**
|
||||
* Called once when a secondary project (identified by projectId query param)
|
||||
* is first accessed via a project-scoped API or SSE request. Use this to
|
||||
* lazily start an engine for that project.
|
||||
*/
|
||||
onProjectFirstAccessed?: (projectId: string) => void;
|
||||
}
|
||||
|
||||
type DashboardExpressApp = ReturnType<typeof express> & {
|
||||
@@ -194,6 +204,44 @@ function shouldScheduleAiSessionCleanup(): boolean {
|
||||
}
|
||||
|
||||
export function createServer(store: TaskStore, options?: ServerOptions): ReturnType<typeof express> {
|
||||
// ── Derive defaults from engine when provided (explicit options override) ──
|
||||
const engine = options?.engine;
|
||||
if (engine) {
|
||||
if (!options!.onMerge) {
|
||||
options = { ...options, onMerge: (taskId: string) => engine.onMerge(taskId) };
|
||||
}
|
||||
if (!options!.automationStore) {
|
||||
options = { ...options, automationStore: engine.getAutomationStore() };
|
||||
}
|
||||
if (!options!.missionAutopilot) {
|
||||
const ma = engine.getRuntime().getMissionAutopilot();
|
||||
if (ma) options = { ...options, missionAutopilot: ma };
|
||||
}
|
||||
if (!options!.missionExecutionLoop) {
|
||||
const mel = engine.getRuntime().getMissionExecutionLoop();
|
||||
if (mel) options = { ...options, missionExecutionLoop: mel };
|
||||
}
|
||||
if (!options!.heartbeatMonitor) {
|
||||
const hb = engine.getHeartbeatMonitor();
|
||||
if (hb) {
|
||||
options = {
|
||||
...options,
|
||||
heartbeatMonitor: {
|
||||
rootDir: engine.getWorkingDirectory(),
|
||||
startRun: hb.startRun.bind(hb),
|
||||
executeHeartbeat: hb.executeHeartbeat.bind(hb),
|
||||
stopRun: hb.stopRun.bind(hb),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register callback for lazy engine startup on secondary projects
|
||||
if (options?.onProjectFirstAccessed) {
|
||||
setOnProjectFirstCreated(options.onProjectFirstAccessed);
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const mutationRateLimit = rateLimit(RATE_LIMITS.mutation);
|
||||
const setupRateLimit = rateLimit(RATE_LIMITS.api);
|
||||
|
||||
Reference in New Issue
Block a user