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:
@@ -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
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
38
packages/dashboard/app/hooks/useProjectContextGuard.ts
Normal file
38
packages/dashboard/app/hooks/useProjectContextGuard.ts
Normal 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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user