Merge pull request #974 from titosemi/fix/chat-sessions-multi-project-scope-clean
fix(dashboard): use per-project chatStore in chat session API routes
This commit is contained in:
@@ -10,6 +10,7 @@ import { createCoreMock, createEngineMock } from "../test/mockCoreEngine.js";
|
||||
function createSSERequest(): Request {
|
||||
const emitter = new EventEmitter();
|
||||
emitter.setMaxListeners(50);
|
||||
(emitter as any).query = {}; // required: routes read req.query.projectId
|
||||
return emitter as unknown as Request;
|
||||
}
|
||||
|
||||
@@ -505,6 +506,46 @@ describe("Chat API Routes", () => {
|
||||
expect(enrichedSession.lastMessagePreview).toBeUndefined();
|
||||
expect(enrichedSession.lastMessageAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses engine chatStore when engineManager is configured for requested projectId", async () => {
|
||||
const { createServer } = await import("../server.js");
|
||||
|
||||
// Engine-scoped chatStore — simulates a secondary project's DB
|
||||
const engineListSessions = vi.fn().mockReturnValue([sampleSession]);
|
||||
const engineChatStore = {
|
||||
...mockChatStoreInstance,
|
||||
listSessions: engineListSessions,
|
||||
getLastMessageForSessions: vi.fn().mockReturnValue(new Map()),
|
||||
};
|
||||
const mockEngine = { getChatStore: () => engineChatStore };
|
||||
const mockEngineManager = {
|
||||
getEngine: vi.fn((id: string) => (id === "proj-secondary" ? mockEngine : undefined)),
|
||||
getAllEngines: vi.fn().mockReturnValue(new Map([["proj-secondary", mockEngine]])),
|
||||
};
|
||||
|
||||
const appWithEngine = createServer(store as any, {
|
||||
chatStore: mockChatStore as any,
|
||||
chatManager: mockChatManager as any,
|
||||
engineManager: mockEngineManager as any,
|
||||
});
|
||||
|
||||
const response = await request(appWithEngine, "GET", "/api/chat/sessions?projectId=proj-secondary");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Engine chatStore was used, not the default one
|
||||
expect(engineListSessions).toHaveBeenCalled();
|
||||
expect(mockListSessions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to default chatStore when no engineManager is configured", async () => {
|
||||
mockListSessions.mockReturnValue([sampleSession]);
|
||||
|
||||
// app has no engineManager (the default setup)
|
||||
const response = await request(app, "GET", "/api/chat/sessions?projectId=proj-001");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockListSessions).toHaveBeenCalledWith({ projectId: "proj-001" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/chat/sessions", () => {
|
||||
@@ -684,6 +725,34 @@ describe("Chat API Routes", () => {
|
||||
expect(response.status).toBe(404);
|
||||
expect((response.body as any).error).toContain("not found");
|
||||
});
|
||||
|
||||
it("uses engine chatStore to find session when projectId is provided", async () => {
|
||||
const { createServer } = await import("../server.js");
|
||||
|
||||
const engineSession = { ...sampleSession, id: "chat-engine-456", projectId: "proj-secondary" };
|
||||
const engineGetSession = vi.fn((id: string) => (id === "chat-engine-456" ? engineSession : undefined));
|
||||
const engineChatStore = { ...mockChatStoreInstance, getSession: engineGetSession };
|
||||
const mockEngine = { getChatStore: () => engineChatStore };
|
||||
const mockEngineManager = {
|
||||
getEngine: vi.fn((id: string) => (id === "proj-secondary" ? mockEngine : undefined)),
|
||||
getAllEngines: vi.fn().mockReturnValue(new Map([["proj-secondary", mockEngine]])),
|
||||
};
|
||||
|
||||
// Default chatStore does NOT have this session
|
||||
mockGetSession.mockReturnValue(undefined);
|
||||
|
||||
const appWithEngine = createServer(store as any, {
|
||||
chatStore: mockChatStore as any,
|
||||
chatManager: mockChatManager as any,
|
||||
engineManager: mockEngineManager as any,
|
||||
});
|
||||
|
||||
const response = await request(appWithEngine, "GET", "/api/chat/sessions/chat-engine-456?projectId=proj-secondary");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect((response.body as any).session.id).toBe("chat-engine-456");
|
||||
expect(engineGetSession).toHaveBeenCalledWith("chat-engine-456");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/chat/sessions/:id", () => {
|
||||
|
||||
@@ -219,6 +219,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
||||
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
|
||||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||||
getFusionDir: vi.fn().mockReturnValue("/fake/root/.fusion"),
|
||||
listWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
createWorkflowStep: vi.fn(),
|
||||
getWorkflowStep: vi.fn(),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AgentStore, ChatStore, type MessageStore, type TaskStore } from "@fusion/core";
|
||||
import type { ProjectEngineManager } from "@fusion/engine";
|
||||
import { ChatManager } from "./chat.js";
|
||||
import { getOrCreateProjectStore } from "./project-store-resolver.js";
|
||||
|
||||
const scopedChatStoreCache = new Map<string, ChatStore>();
|
||||
|
||||
@@ -38,20 +37,29 @@ export async function resolveProjectChatContext(options: {
|
||||
};
|
||||
}
|
||||
|
||||
const engine = engineManager?.getEngine(projectId);
|
||||
try {
|
||||
const scopedStore = engine?.getTaskStore() ?? await getOrCreateProjectStore(projectId);
|
||||
const engineChatStore = engine?.getChatStore?.();
|
||||
return {
|
||||
store: scopedStore,
|
||||
chatStore: getOrCreateScopedChatStore(scopedStore, engineChatStore),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
store: defaultStore,
|
||||
chatStore: getOrCreateScopedChatStore(defaultStore, defaultChatStore),
|
||||
};
|
||||
// Only use engine path when an engine is actually found for this project.
|
||||
if (engineManager) {
|
||||
const engine = engineManager.getEngine(projectId);
|
||||
if (engine) {
|
||||
try {
|
||||
const scopedStore = engine.getTaskStore?.() ?? defaultStore;
|
||||
const engineChatStore = engine.getChatStore?.();
|
||||
return {
|
||||
store: scopedStore,
|
||||
chatStore: getOrCreateScopedChatStore(scopedStore, engineChatStore),
|
||||
};
|
||||
} catch {
|
||||
// engine's store not accessible — fall through to default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No engine for this project — use the default store.
|
||||
// Route handlers apply projectId filtering at the query level.
|
||||
return {
|
||||
store: defaultStore,
|
||||
chatStore: getOrCreateScopedChatStore(defaultStore, defaultChatStore),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createProjectScopedChatManager(options: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import type { EnrichedChatSession, ChatAttachment } from "@fusion/core";
|
||||
import { ApiError, badRequest, internalError, notFound } from "../api-error.js";
|
||||
import { resolveProjectChatContext } from "../chat-project-services.js";
|
||||
import { CHAT_ALLOWED_MIME_TYPES, CHAT_MAX_ATTACHMENT_SIZE } from "./chat-attachment-config.js";
|
||||
import { rateLimit, RATE_LIMITS } from "../rate-limit.js";
|
||||
import { writeSSEEvent, type SessionBufferedEvent } from "../sse-buffer.js";
|
||||
@@ -27,9 +28,18 @@ function resolveAttachmentPath(rootDir: string, sessionId: string, filename: str
|
||||
}
|
||||
|
||||
export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): void {
|
||||
const { router, options, getProjectContext, chatLogger, rethrowAsApiError } = ctx;
|
||||
const { router, options, store, getProjectContext, chatLogger, rethrowAsApiError } = ctx;
|
||||
const { parseLastEventId, replayBufferedSSE, validateOptionalModelField, upload } = deps;
|
||||
|
||||
async function resolveScopedChatStore(projectId: string | undefined) {
|
||||
return resolveProjectChatContext({
|
||||
projectId,
|
||||
defaultStore: store,
|
||||
defaultChatStore: options?.chatStore,
|
||||
engineManager: options?.engineManager,
|
||||
});
|
||||
}
|
||||
|
||||
const uploadChatAttachment: import("express").RequestHandler = (req, res, next) => {
|
||||
upload.single("file")(req, res, (err?: unknown) => {
|
||||
if (!err) {
|
||||
@@ -56,11 +66,6 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
*/
|
||||
router.get("/chat/sessions", rateLimit(RATE_LIMITS.api), async (req, res) => {
|
||||
try {
|
||||
const chatStore = options?.chatStore;
|
||||
if (!chatStore) {
|
||||
throw internalError("Chat store not available");
|
||||
}
|
||||
|
||||
const { projectId, status, agentId, lookup, modelProvider, modelId } = req.query as {
|
||||
projectId?: string;
|
||||
status?: string;
|
||||
@@ -69,6 +74,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
modelProvider?: string;
|
||||
modelId?: string;
|
||||
};
|
||||
const { chatStore } = await resolveScopedChatStore(projectId);
|
||||
|
||||
const isResumeLookup = lookup === "resume";
|
||||
const hasModelProvider = typeof modelProvider === "string" && modelProvider.trim().length > 0;
|
||||
@@ -144,13 +150,9 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
*/
|
||||
router.post("/chat/sessions", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
|
||||
try {
|
||||
const chatStore = options?.chatStore;
|
||||
if (!chatStore) {
|
||||
throw internalError("Chat store not available");
|
||||
}
|
||||
|
||||
// Get project context to scope the session and resolve agent from the correct store
|
||||
const { store: scopedStore, projectId } = await getProjectContext(req);
|
||||
const { chatStore } = await resolveScopedChatStore(projectId);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
@@ -220,10 +222,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
*/
|
||||
router.get("/chat/sessions/:id", async (req, res) => {
|
||||
try {
|
||||
const chatStore = options?.chatStore;
|
||||
if (!chatStore) {
|
||||
throw internalError("Chat store not available");
|
||||
}
|
||||
const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
|
||||
|
||||
const sessionId = String(req.params.id);
|
||||
const session = chatStore.getSession(sessionId);
|
||||
@@ -250,10 +249,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
*/
|
||||
router.patch("/chat/sessions/:id", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
|
||||
try {
|
||||
const chatStore = options?.chatStore;
|
||||
if (!chatStore) {
|
||||
throw internalError("Chat store not available");
|
||||
}
|
||||
const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
|
||||
|
||||
const sessionId = String(req.params.id);
|
||||
const { title, status } = req.body as { title?: string; status?: string };
|
||||
@@ -287,11 +283,8 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
*/
|
||||
router.delete("/chat/sessions/:id", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
|
||||
try {
|
||||
const chatStore = options?.chatStore;
|
||||
const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
|
||||
const sessionId = String(req.params.id);
|
||||
if (!chatStore) {
|
||||
throw internalError("Chat store not available");
|
||||
}
|
||||
|
||||
const deleted = chatStore.deleteSession(sessionId);
|
||||
if (!deleted) {
|
||||
@@ -314,10 +307,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
*/
|
||||
router.get("/chat/sessions/:id/messages", async (req, res) => {
|
||||
try {
|
||||
const chatStore = options?.chatStore;
|
||||
if (!chatStore) {
|
||||
throw internalError("Chat store not available");
|
||||
}
|
||||
const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
|
||||
|
||||
const sessionId = String(req.params.id);
|
||||
|
||||
@@ -363,10 +353,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
|
||||
router.post("/chat/sessions/:id/attachments", rateLimit(RATE_LIMITS.mutation), uploadChatAttachment, async (req, res) => {
|
||||
try {
|
||||
const chatStore = options?.chatStore;
|
||||
if (!chatStore) {
|
||||
throw internalError("Chat store not available");
|
||||
}
|
||||
const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
|
||||
|
||||
const sessionId = String(req.params.id);
|
||||
const session = chatStore.getSession(sessionId);
|
||||
@@ -456,10 +443,10 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
*/
|
||||
router.get("/chat/sessions/:id/stream", rateLimit(RATE_LIMITS.sse), async (req, res) => {
|
||||
try {
|
||||
const chatStore = options?.chatStore;
|
||||
const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
|
||||
const chatManager = options?.chatManager;
|
||||
if (!chatStore || !chatManager) {
|
||||
throw internalError("Chat store or manager not available");
|
||||
if (!chatManager) {
|
||||
throw internalError("Chat manager not available");
|
||||
}
|
||||
|
||||
const sessionId = String(req.params.id);
|
||||
@@ -545,10 +532,10 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
*/
|
||||
router.post("/chat/sessions/:id/messages", rateLimit(RATE_LIMITS.sse), async (req, res) => {
|
||||
try {
|
||||
const chatStore = options?.chatStore;
|
||||
const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
|
||||
const chatManager = options?.chatManager;
|
||||
if (!chatStore || !chatManager) {
|
||||
throw internalError("Chat store or manager not available");
|
||||
if (!chatManager) {
|
||||
throw internalError("Chat manager not available");
|
||||
}
|
||||
|
||||
const { content, modelProvider, modelId, attachments } = req.body as {
|
||||
@@ -711,10 +698,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
*/
|
||||
router.delete("/chat/sessions/:id/messages/:messageId", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
|
||||
try {
|
||||
const chatStore = options?.chatStore;
|
||||
if (!chatStore) {
|
||||
throw internalError("Chat store not available");
|
||||
}
|
||||
const { chatStore } = await resolveScopedChatStore(req.query.projectId as string | undefined);
|
||||
|
||||
const sessionId = String(req.params.id);
|
||||
const messageId = String(req.params.messageId);
|
||||
|
||||
Reference in New Issue
Block a user