FN-8366: enforce end-to-end project scoping

Ensure asynchronous dashboard data and real-time streams remain bound to their active project context.

- Resolve SSE and badge WebSocket stores through the canonical project resolver
- Guard agents, artifacts, and documents hooks against stale project responses and events
- Add scoped cache handling, regression coverage, and architecture documentation

Files changed:
 docs/architecture.md                               |  7 ++++
 .../app/hooks/__tests__/useAgents.test.ts          | 24 +++++++++++
 .../app/hooks/__tests__/useArtifacts.test.ts       | 47 ++++++++++++++++++++++
 .../app/hooks/__tests__/useDocuments.test.ts       | 34 ++++++++++++++++
 packages/dashboard/app/hooks/useAgents.ts          | 47 +++++++++++++++++-----
 packages/dashboard/app/hooks/useArtifacts.ts       | 26 +++++++++---
 packages/dashboard/app/hooks/useDocuments.ts       | 18 ++++++++-
 .../dashboard/app/hooks/useProjectContextGuard.ts  | 38 +++++++++++++++++
 .../routes-context-project-identity.test.ts        | 12 ++++++
 packages/dashboard/src/server.ts                   | 21 +++++++---
 10 files changed, 251 insertions(+), 23 deletions(-)

Fusion-Task-Id: FN-8366
Fusion-Task-Lineage: 0fcd38c1-727f-4511-8209-5d70cbf9eb0d
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-19 16:29:39 -07:00
parent d768501274
commit efa9580b93
10 changed files with 251 additions and 23 deletions

View File

@@ -959,6 +959,13 @@ Key server capabilities:
- companion endpoints: `/api/dev-server/detect`, `/config`, `/status`, `/start`, `/stop`, `/restart`, `/preview-url`
- **Badge WebSocket**: `/api/ws` (`server.ts`, `websocket.ts`)
- Scope-keyed channels (`badge:{scopeKey}:{taskId}`) prevent cross-project collisions
#### End-to-end project-scoping invariant
- Every task-data route resolves its request project once through `getProjectContext`/`getScopedStore` (or the canonical resolver used by real-time providers) and uses that resolved store for the whole handler. A supplied `projectId` never falls back to the injected/default store; unscoped launches retain the resolver's single-project fallback.
- `/api/events` and `/api/ws` bind their listeners to that same request-scoped store. Their project-scoped channel keys and listeners prevent events from one project's task IDs from reaching another project, including when IDs overlap.
- Project-scoped client hooks capture the active project context/version before starting fetches, SSE subscriptions, WebSocket callbacks, or reconnect work. Before applying an async result or event they reject work whose captured context is stale, emit the shared `dropped-stale-event` dashboard trace where applicable, and leave the newly active project's state untouched.
- The canonical client implementation is `useProjectContextGuard`, as used alongside the established `useTasks` and badge-WebSocket guards. New project-scoped hooks must reuse this capture-at-start and stale-drop pattern rather than independently comparing mutable current-project state.
- **Terminal WebSocket**: `/api/terminal/ws` (`server.ts`, `terminal-service.ts`)
- Project-scoped terminal session validation + safe unscoped fallback

View File

@@ -190,6 +190,30 @@ describe("useAgents", () => {
expect(console.error).toHaveBeenCalledWith("Failed to load agent stats:", expect.any(Error));
});
it("hydrates and clears agent state per project on a project switch", async () => {
const agentsA = [createAgent({ id: "agent-a", state: "active" })];
const agentsB = [createAgent({ id: "agent-b", state: "active" })];
const statsB = { ...defaultStats, activeCount: 7 };
window.localStorage.setItem(`${SWR_CACHE_KEYS.AGENTS}:project-b`, JSON.stringify(agentsB));
window.localStorage.setItem(`${SWR_CACHE_KEYS.AGENT_STATS}:project-b`, JSON.stringify(statsB));
mockFetchAgents.mockImplementation((_filter, projectId) => Promise.resolve(projectId === "project-b" ? agentsB : agentsA));
mockFetchAgentStats.mockImplementation((projectId) => Promise.resolve(projectId === "project-b" ? statsB : defaultStats));
const { result, rerender } = renderHook(
({ projectId }: { projectId: string }) => useAgents(projectId),
{ initialProps: { projectId: "project-a" } },
);
await waitFor(() => expect(result.current.agents).toEqual(agentsA));
rerender({ projectId: "project-b" });
await waitFor(() => {
expect(result.current.agents).toEqual(agentsB);
expect(result.current.stats).toEqual(statsB);
});
expect(result.current.agents).not.toContainEqual(expect.objectContaining({ id: "agent-a" }));
});
it("creates SSE subscription with correct URL without projectId", async () => {
renderHook(() => useAgents());

View File

@@ -5,6 +5,7 @@ import { fetchArtifacts } from "../../api";
import { subscribeSse } from "../../sse-bus";
import { useArtifacts } from "../useArtifacts";
import { message } from "./sseTestHelpers";
import { clearTraces, getTraces } from "../../utils/dashboardTraceBuffer";
const { handlers, unsubscribeMock } = vi.hoisted(() => ({
handlers: {} as Record<string, (event: MessageEvent) => void>,
@@ -71,6 +72,7 @@ describe("useArtifacts", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
window.localStorage.clear();
clearTraces();
for (const key of Object.keys(handlers)) delete handlers[key];
unsubscribeMock.mockClear();
mockFetchArtifacts.mockResolvedValue(mockArtifacts);
@@ -304,6 +306,51 @@ describe("useArtifacts", () => {
await waitFor(() => expect(mockFetchArtifacts).toHaveBeenCalledTimes(1));
});
it("drops a late artifact response after the active project switches", async () => {
const { result, rerender } = renderHook(
({ projectId }) => useArtifacts({ projectId }),
{ initialProps: { projectId: "project-a" } },
);
await loadInitialArtifacts();
await waitFor(() => expect(result.current.artifacts).toEqual(mockArtifacts));
let resolveOldRequest!: (value: ArtifactWithTask[]) => void;
mockFetchArtifacts.mockImplementationOnce(() => new Promise((resolve) => {
resolveOldRequest = resolve;
}));
void result.current.refresh();
rerender({ projectId: "project-b" });
await act(async () => {
resolveOldRequest([{ ...mockArtifacts[0], id: "project-a-artifact" }]);
});
expect(result.current.artifacts).not.toContainEqual(expect.objectContaining({ id: "project-a-artifact" }));
expect(getTraces()).toContainEqual(expect.objectContaining({ source: "useArtifacts", event: "dropped-stale-event" }));
});
it("drops a late artifact rejection after the active project switches", async () => {
const { result, rerender } = renderHook(
({ projectId }) => useArtifacts({ projectId }),
{ initialProps: { projectId: "project-a" } },
);
await loadInitialArtifacts();
let rejectOldRequest!: (reason: Error) => void;
mockFetchArtifacts.mockImplementationOnce(() => new Promise((_, reject) => {
rejectOldRequest = reject;
}));
void result.current.refresh();
rerender({ projectId: "project-b" });
await act(async () => {
rejectOldRequest(new Error("project-a failure"));
});
expect(result.current.error).toBeNull();
expect(getTraces()).toContainEqual(expect.objectContaining({ source: "useArtifacts", event: "dropped-stale-event" }));
});
it("subscribes to project-scoped artifact registration events and unsubscribes on unmount", async () => {
const { unmount } = renderHook(() => useArtifacts({ projectId: "project-13" }));

View File

@@ -224,6 +224,40 @@ describe("useDocuments", () => {
});
});
it("clears project A workspace files immediately when switching to project B", async () => {
let resolveProjectBFileRequest: ((response: Response) => void) | undefined;
globalThis.fetch = vi.fn().mockImplementation((input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("projectId=project-b")) {
if (url.includes("/files/markdown-list")) {
return new Promise<Response>((resolve) => {
resolveProjectBFileRequest = resolve;
});
}
return new Promise<Response>(() => {});
}
return mockFetchResponse(true, url.includes("/files/markdown-list")
? { files: mockProjectFiles }
: mockDocuments);
});
const { result, rerender } = renderHook(
({ projectId }) => useDocuments({ projectId }),
{ initialProps: { projectId: "project-a" } },
);
await act(async () => {
await vi.runAllTimersAsync();
});
await waitFor(() => expect(result.current.projectFiles).toEqual(mockProjectFiles));
rerender({ projectId: "project-b" });
await waitFor(() => expect(result.current.projectFiles).toEqual([]));
await act(async () => {
resolveProjectBFileRequest?.(await mockFetchResponse(true, { files: [] }));
});
});
it("cancels in-flight request on unmount", async () => {
const abortMock = vi.fn();
const originalAbortController = globalThis.AbortController;

View File

@@ -4,6 +4,7 @@ import { fetchAgents, fetchAgentStats } from "../api";
import { isEphemeralAgent } from "@fusion/core";
import { subscribeSse } from "../sse-bus";
import { readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, writeCache } from "../utils/swrCache";
import { useProjectContextGuard } from "./useProjectContextGuard";
interface UseAgentsOptions {
filterState?: AgentState | "all";
@@ -23,12 +24,14 @@ interface AgentFilter {
const SSE_REFRESH_DEBOUNCE_MS = 250;
export function useAgents(projectId?: string, options?: UseAgentsOptions) {
const agentsCacheKey = projectId ? `${SWR_CACHE_KEYS.AGENTS}:${projectId}` : SWR_CACHE_KEYS.AGENTS;
const statsCacheKey = projectId ? `${SWR_CACHE_KEYS.AGENT_STATS}:${projectId}` : SWR_CACHE_KEYS.AGENT_STATS;
const [agents, setAgents] = useState<Agent[]>(() => {
const cached = readCache<Agent[]>(SWR_CACHE_KEYS.AGENTS, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
const cached = readCache<Agent[]>(agentsCacheKey, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
return Array.isArray(cached) ? cached : [];
});
const [stats, setStats] = useState<AgentStats | null>(() => {
const cached = readCache<AgentStats>(SWR_CACHE_KEYS.AGENT_STATS, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
const cached = readCache<AgentStats>(statsCacheKey, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
return cached ?? null;
});
const [isLoading, setIsLoading] = useState(false);
@@ -52,8 +55,31 @@ export function useAgents(projectId?: string, options?: UseAgentsOptions) {
// next SSE event.
const agentsGenRef = useRef(0);
const statsGenRef = useRef(0);
/*
FNXC:ProjectScoping 2026-07-15-20:10:
Agent snapshots and their SSE-triggered refreshes are scoped to the project
captured at dispatch, so a stale prior-project response cannot replace B.
*/
const { capture } = useProjectContextGuard(projectId, "useAgents");
useEffect(() => {
/*
FNXC:ProjectScoping 2026-07-16-00:00:
Agent cache hydration is keyed by project. On a project switch, clear A's
snapshot immediately or hydrate only B's cache while B's scoped request runs.
*/
const cachedAgents = readCache<Agent[]>(agentsCacheKey, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
const cachedStats = readCache<AgentStats>(statsCacheKey, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
const nextAgents = Array.isArray(cachedAgents) ? cachedAgents : [];
const nextStats = cachedStats ?? null;
setAgents(nextAgents);
setStats(nextStats);
hasCachedHydrationRef.current = nextAgents.length > 0 || nextStats !== null;
setIsLoading(false);
}, [agentsCacheKey, statsCacheKey]);
const loadAgents = useCallback(async (filter?: AgentFilter, opts?: { forceFresh?: boolean }) => {
const context = capture();
const gen = ++agentsGenRef.current;
if (!hasCachedHydrationRef.current) {
setIsLoading(true);
@@ -72,36 +98,37 @@ export function useAgents(projectId?: string, options?: UseAgentsOptions) {
: await fetchAgents(mergedFilter, projectId);
// A newer call superseded us — drop this response so we don't clobber
// fresher state with stale data.
if (gen !== agentsGenRef.current) return;
if (gen !== agentsGenRef.current || context.isStale()) return;
// Defensive dedupe: a race between the initial fetch and an SSE refresh
// (or a backend that returned the same agent twice) would otherwise put
// duplicate ids into every list rendered from this hook, flooding React
// with duplicate-key warnings until the dashboard runs out of heap.
const unique = Array.from(new Map(data.map((a) => [a.id, a])).values());
setAgents(unique);
writeCache(SWR_CACHE_KEYS.AGENTS, unique, { maxBytes: 500_000 });
writeCache(agentsCacheKey, unique, { maxBytes: 500_000 });
hasCachedHydrationRef.current = hasCachedHydrationRef.current || unique.length > 0;
} catch (err) {
console.error("Failed to load agents:", err);
} finally {
if (gen === agentsGenRef.current) setIsLoading(false);
}
}, [projectId, options?.filterState, options?.showSystemAgents]);
}, [agentsCacheKey, capture, projectId, options?.filterState, options?.showSystemAgents]);
const loadStats = useCallback(async (opts?: { forceFresh?: boolean }) => {
const context = capture();
const gen = ++statsGenRef.current;
try {
const data = opts?.forceFresh
? await fetchAgentStats(projectId, { forceFresh: true })
: await fetchAgentStats(projectId);
if (gen !== statsGenRef.current) return;
if (gen !== statsGenRef.current || context.isStale()) return;
setStats(data);
writeCache(SWR_CACHE_KEYS.AGENT_STATS, data, { maxBytes: 500_000 });
writeCache(statsCacheKey, data, { maxBytes: 500_000 });
hasCachedHydrationRef.current = true;
} catch (err) {
console.error("Failed to load agent stats:", err);
}
}, [projectId]);
}, [capture, projectId, statsCacheKey]);
useEffect(() => {
void loadAgents();
@@ -123,6 +150,7 @@ export function useAgents(projectId?: string, options?: UseAgentsOptions) {
// fetch+debounce window, we issue at most 2 fetches (the initial debounced
// fetch and one trailing catch-up).
useEffect(() => {
const context = capture();
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
@@ -161,6 +189,7 @@ export function useAgents(projectId?: string, options?: UseAgentsOptions) {
};
const refresh = (): void => {
if (context.isStale()) return;
if (fetchInProgress) {
// Mark a trailing refresh; don't schedule a new timer that would race
// the in-flight fetch and (via forceFresh) discard its response.
@@ -187,7 +216,7 @@ export function useAgents(projectId?: string, options?: UseAgentsOptions) {
if (debounceTimer) clearTimeout(debounceTimer);
unsubscribe();
};
}, [projectId, loadAgents, loadStats]);
}, [capture, projectId, loadAgents, loadStats]);
// refreshAgents is the canonical post-mutation refetch entrypoint. It
// defaults to forceFresh so consumers don't have to remember — anyone

View File

@@ -3,6 +3,7 @@ import type { ArtifactType, ArtifactWithTask } from "@fusion/core";
import { fetchArtifacts } from "../api";
import { subscribeSse } from "../sse-bus";
import { readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, writeCache } from "../utils/swrCache";
import { useProjectContextGuard } from "./useProjectContextGuard";
export interface UseArtifactsResult {
/** List of artifacts across agents and tasks */
@@ -46,8 +47,15 @@ export function useArtifacts(options?: {
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const refreshRef = useRef<() => Promise<void>>(async () => {});
const sseRefreshDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/*
FNXC:ProjectScoping 2026-07-15-20:10:
Artifact fetches and SSE callbacks capture their project before work begins;
the shared guard drops delayed project-A results after the view switches to B.
*/
const { capture } = useProjectContextGuard(projectId, "useArtifacts");
const refresh = useCallback(async () => {
const context = capture();
if (abortRef.current) {
abortRef.current.abort();
}
@@ -69,7 +77,7 @@ export function useArtifacts(options?: {
q: searchQuery,
}, projectId);
if (requestController.signal.aborted) {
if (requestController.signal.aborted || context.isStale()) {
return;
}
@@ -80,16 +88,21 @@ export function useArtifacts(options?: {
}
initialLoadCompleteRef.current = true;
} catch (err) {
if (requestController.signal.aborted) {
if (requestController.signal.aborted || context.isStale()) {
return;
}
setError(err instanceof Error ? err.message : String(err));
} finally {
if (!requestController.signal.aborted && isInitial) {
/*
FNXC:ProjectScoping 2026-07-16-00:00:
A stale request must not clear the new project's loading indicator after
a project switch, including when the old request rejects.
*/
if (!requestController.signal.aborted && !context.isStale() && isInitial) {
setLoading(false);
}
}
}, [authorId, cacheKey, projectId, searchQuery, taskId, type]);
}, [authorId, cacheKey, capture, projectId, searchQuery, taskId, type]);
useEffect(() => {
refreshRef.current = refresh;
@@ -125,6 +138,7 @@ export function useArtifacts(options?: {
}, [refresh]);
useEffect(() => {
const context = capture();
/*
* FNXC:ArtifactRegistry 2026-06-27-00:00:
* Already-open task and project artifact lists must live-refresh from TaskStore's authoritative artifact:registered SSE event and also accept the best-effort agent/chat message notifications as an additional signal. Task-scoped hooks filter by artifact/task metadata while project-scoped hooks rely on the projectId refetch and optional payload projectId guard, preserving SWR cached rendering, search filters, and scoped fetch behavior without showing a loading flash.
@@ -145,7 +159,7 @@ export function useArtifacts(options?: {
};
const artifactId = source === "artifact" ? payload.id : payload.metadata?.artifactId;
const artifactTaskId = source === "artifact" ? payload.taskId : payload.metadata?.taskId;
if (!artifactId) return;
if (!artifactId || context.isStale()) return;
if (projectId && payload.projectId && payload.projectId !== projectId) return;
if (taskId && artifactTaskId !== taskId) return;
@@ -186,7 +200,7 @@ export function useArtifacts(options?: {
sseRefreshDebounceRef.current = null;
}
};
}, [projectId, taskId]);
}, [capture, projectId, taskId]);
useEffect(() => {
void refresh();

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react";
import type { TaskDocumentWithTask } from "@fusion/core";
import { fetchAllDocuments, fetchProjectMarkdownFiles, type MarkdownFileEntry } from "../api";
import { readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, writeCache } from "../utils/swrCache";
import { useProjectContextGuard } from "./useProjectContextGuard";
export interface UseDocumentsResult {
/** List of all documents across tasks */
@@ -49,12 +50,19 @@ export function useDocuments(options?: {
const initialLoadCompleteRef = useRef(documents.length > 0);
// Debounce timer for search
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/*
FNXC:ProjectScoping 2026-07-15-20:10:
Documents and workspace-file responses must match the project captured when
the request started; the shared guard rejects a late old-project payload.
*/
const { capture } = useProjectContextGuard(projectId, "useDocuments");
/**
* Fetch documents from the server.
* Background updates (refresh, search) do NOT set loading=true.
*/
const refresh = useCallback(async () => {
const context = capture();
// Cancel any in-flight requests
if (abortRef.current) {
abortRef.current.abort();
@@ -84,7 +92,7 @@ export function useDocuments(options?: {
projectFileFetchPromise,
]);
if (requestController.signal.aborted) {
if (requestController.signal.aborted || context.isStale()) {
return;
}
@@ -119,9 +127,15 @@ export function useDocuments(options?: {
if (isInitial) {
setLoading(false);
}
}, [cacheKey, includeProjectFiles, projectId, searchQuery]);
}, [cacheKey, capture, includeProjectFiles, projectId, searchQuery]);
useEffect(() => {
/*
FNXC:ProjectScoping 2026-07-16-00:00:
Workspace markdown files are project-scoped alongside task documents. Clear
them during a switch so project A paths cannot render while B loads.
*/
setProjectFiles([]);
if (!cacheKey) {
initialLoadCompleteRef.current = false;
setDocuments([]);

View File

@@ -0,0 +1,38 @@
import { useCallback, useRef } from "react";
import { pushTrace } from "../utils/dashboardTraceBuffer";
/**
* FNXC:ProjectScoping 2026-07-15-20:10:
* Project-scoped hooks capture this monotonically increasing context version
* before an async request or SSE callback is registered. A changed active
* project invalidates that work, preventing a late prior-project result from
* being applied to the current view.
*/
export function useProjectContextGuard(projectId: string | undefined, source: string) {
const versionRef = useRef(0);
const previousProjectIdRef = useRef<string | undefined>(projectId);
const projectIdRef = useRef<string | undefined>(projectId);
projectIdRef.current = projectId;
if (previousProjectIdRef.current !== projectId) {
previousProjectIdRef.current = projectId;
versionRef.current += 1;
}
const capture = useCallback(() => {
const projectIdAtStart = projectIdRef.current;
const contextVersionAtStart = versionRef.current;
return {
projectIdAtStart,
isStale: () => {
const stale = versionRef.current !== contextVersionAtStart || projectIdRef.current !== projectIdAtStart;
if (stale) {
pushTrace(source, "dropped-stale-event", { projectId: projectIdAtStart });
}
return stale;
},
};
}, [source]);
return { capture };
}

View File

@@ -154,6 +154,18 @@ describe("routes/context central project identity seam", () => {
expect(projectStoreResolver.getOrCreateProjectStore).not.toHaveBeenCalled();
});
/*
* FNXC:ProjectScoping 2026-07-15-20:20:
* An explicit launch project id on SSE/WebSocket must reuse the injected
* launch store, not create a second EventEmitter-bearing scoped store.
*/
it("explicit launch id → injected store, no duplicate realtime binding", async () => {
const options = { engine: makeEngine("launch-proj", tag("engine-store")) } as unknown as ServerOptions;
const store = await resolveScopedStore("launch-proj", rawLaunchStore, undefined, "launch-proj", options);
expect(store).toBe(rawLaunchStore);
expect(projectStoreResolver.getOrCreateProjectStore).not.toHaveBeenCalled();
});
it("explicit id ≠ launch, getEngine undefined → getOrCreateProjectStore", async () => {
const options = { engine: makeEngine("launch-proj", tag("engine-store")) } as unknown as ServerOptions;
const store = await resolveScopedStore("other-proj", rawLaunchStore, undefined, "launch-proj", options);

View File

@@ -25,7 +25,6 @@ import { ApiError, sendErrorResponse } from "./api-error.js";
import {
countRunningAgentsInRegisteredProjectStores,
countRunningAgentsInStore,
getOrCreateProjectStore,
evictAllProjectStores,
setOnProjectFirstCreated,
} from "./project-store-resolver.js";
@@ -1188,21 +1187,26 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
// 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;
const scopedStore: TaskStore = await resolveProjectScopedStore(projectId);
let agentStore: AgentStore | undefined;
let messageStore: MessageStore | undefined;
let automationStore: AutomationStore | undefined;
let scopedChatStore = chatStore;
/*
FNXC:ProjectScoping 2026-07-15-20:10:
A project-scoped SSE stream must attach to the same canonical TaskStore as
request handlers and the launch engine. Reimplementing the resolver here
created a second store for the launch project when its engine was not
immediately available, which separated its EventEmitter from mutations.
*/
if (engineManager) {
const engine = engineManager.getEngine(projectId);
scopedStore = engine?.getTaskStore() ?? await getOrCreateProjectStore(projectId);
scopedChatStore = getOrCreateScopedChatStore(scopedStore, engine?.getChatStore?.());
// Use the engine's stores if available
// Use the engine's auxiliary stores when available.
agentStore = engine?.getAgentStore();
messageStore = engine?.getMessageStore();
automationStore = engine?.getAutomationStore();
} else {
scopedStore = await getOrCreateProjectStore(projectId);
scopedChatStore = getOrCreateScopedChatStore(scopedStore);
}
// Fallback: create AgentStore if engine doesn't have one
@@ -2553,7 +2557,12 @@ export function setupBadgeWebSocket(
return scopedStore;
}
// Create scoped store
/*
FNXC:ProjectScoping 2026-07-15-20:10:
Badge WebSocket listeners use the canonical scoped resolver, including for
the launch project, so an explicit `projectId` cannot bind a duplicate
TaskStore/EventEmitter and leak or miss cross-project badge updates.
*/
scopedStore = await resolveScopedStore(projectId, store, options?.engineManager, options?.engine?.getProjectId?.(), options);
scopedStores.set(projectId, scopedStore);
return scopedStore;