feat(FN-1776): expose AgentStore for SSE event forwarding with ephemeral agent filtering
- Expose getAgentStore from runtime and engine packages - Add includeSystem filter to getOrgTree API endpoint - Wire AgentStore into SSE endpoint for event forwarding - Forward agent events through the SSE pipeline - Exclude ephemeral agents from org tree API and UI - Filter ephemeral agents in useAgents hook - Update AgentsView test to expect includeSystem filter - Document agent SSE event forwarding architecture
This commit is contained in:
@@ -9,6 +9,13 @@
|
||||
- `agent.metadata?.managedBy === "task-executor"` — executor-managed agents
|
||||
- Default: `includeSystem: false` excludes these from the agents page UI
|
||||
|
||||
- **`FN-1776 Agent SSE Event Forwarding`**: Agent lifecycle events are now forwarded through the SSE pipeline:
|
||||
- `createSSE()` accepts an optional `AgentStore` parameter for forwarding `agent:created`, `agent:updated`, `agent:deleted`, and `agent:stateChanged` events
|
||||
- `getOrgTree()` accepts `{ includeSystem?: boolean }` filter matching `listAgents()` pattern
|
||||
- `GET /api/agents/org-tree` supports `includeSystem` query parameter
|
||||
- `useAgents` hook passes `includeSystem: false` by default, excluding ephemeral agents
|
||||
- Server SSE endpoint resolves `AgentStore` from engine via `getAgentStore()` for project-scoped streams
|
||||
|
||||
- **`FN-1736 Multi-Project Scoping Audit`**: Comprehensive audit of project-scoping across the Fusion stack found:
|
||||
- SSE/WebSocket endpoints (`/api/tasks/:id/logs/stream`, `/api/events`, `/api/ws`) already use `resolveProjectScopedStore()` or `getProjectContext()` correctly
|
||||
- Badge WebSocket (`setupBadgeWebSocket`) properly scopes per-project with listeners on scoped stores
|
||||
|
||||
@@ -1500,10 +1500,11 @@ export class AgentStore extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Build the recursive org tree for all agents.
|
||||
* @param filter - Optional filter for listing agents
|
||||
* @returns Root nodes with nested children
|
||||
*/
|
||||
async getOrgTree(): Promise<OrgTreeNode[]> {
|
||||
const agents = await this.listAgents();
|
||||
async getOrgTree(filter?: { includeSystem?: boolean }): Promise<OrgTreeNode[]> {
|
||||
const agents = await this.listAgents(filter);
|
||||
if (agents.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -2615,8 +2615,12 @@ export function fetchChainOfCommand(agentId: string, projectId?: string): Promis
|
||||
}
|
||||
|
||||
/** Fetch the full org tree as nested nodes */
|
||||
export function fetchOrgTree(projectId?: string): Promise<OrgTreeNode[]> {
|
||||
return api<OrgTreeNode[]>(withProjectId("/agents/org-tree", projectId));
|
||||
export function fetchOrgTree(projectId?: string, options?: { includeSystem?: boolean }): Promise<OrgTreeNode[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (projectId) params.set("projectId", projectId);
|
||||
if (options?.includeSystem) params.set("includeSystem", "true");
|
||||
const query = params.toString();
|
||||
return api<OrgTreeNode[]>(`/agents/org-tree${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
/** Resolve an agent by shortname or ID */
|
||||
|
||||
@@ -306,7 +306,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
|
||||
let cancelled = false;
|
||||
setIsOrgTreeLoading(true);
|
||||
fetchOrgTree(projectId)
|
||||
fetchOrgTree(projectId, { includeSystem: showSystemAgents })
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setOrgTree(data);
|
||||
@@ -327,7 +327,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentView, projectId, addToast]);
|
||||
}, [agentView, projectId, showSystemAgents, addToast]);
|
||||
|
||||
// Refresh agent list on SSE events (independent from useAgents hook state)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -121,7 +121,7 @@ describe("AgentsView", () => {
|
||||
it("passes projectId to agent fetches", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenCalledWith(undefined, projectId);
|
||||
expect(mockFetchAgents).toHaveBeenCalledWith({ includeSystem: false }, projectId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -341,7 +341,7 @@ describe("AgentsView", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchOrgTree).toHaveBeenCalledWith(projectId);
|
||||
expect(mockFetchOrgTree).toHaveBeenCalledWith(projectId, { includeSystem: false });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ describe("useAgents", () => {
|
||||
await result.current.loadAgents({ state: "active", role: "executor" });
|
||||
});
|
||||
|
||||
expect(mockFetchAgents).toHaveBeenLastCalledWith({ state: "active", role: "executor" }, undefined);
|
||||
expect(mockFetchAgents).toHaveBeenLastCalledWith({ state: "active", role: "executor", includeSystem: false }, undefined);
|
||||
});
|
||||
|
||||
it("handles fetchAgents rejection gracefully", async () => {
|
||||
@@ -187,7 +187,7 @@ describe("useAgents", () => {
|
||||
renderHook(() => useAgents(projectId));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenCalledWith(undefined, projectId);
|
||||
expect(mockFetchAgents).toHaveBeenCalledWith({ includeSystem: false }, projectId);
|
||||
expect(mockFetchAgentStats).toHaveBeenCalledWith(projectId);
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export function useAgents(projectId?: string) {
|
||||
const loadAgents = useCallback(async (filter?: { state?: AgentState; role?: AgentCapability }) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchAgents(filter, projectId);
|
||||
const data = await fetchAgents({ ...filter, includeSystem: false }, projectId);
|
||||
setAgents(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load agents:", err);
|
||||
|
||||
@@ -10597,7 +10597,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const tree = await agentStore.getOrgTree();
|
||||
const includeSystem = req.query.includeSystem === "true";
|
||||
const tree = await agentStore.getOrgTree({ includeSystem });
|
||||
res.json(tree);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
|
||||
@@ -359,7 +359,11 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
const engineManager = options?.engineManager;
|
||||
|
||||
if (!projectId) {
|
||||
createSSE(store, store.getMissionStore(), aiSessionStore, store.getPluginStore())(req, res);
|
||||
// Create AgentStore for default project SSE
|
||||
const { AgentStore: AgentStoreClass } = await import("@fusion/core");
|
||||
const defaultAgentStore = new AgentStoreClass({ rootDir: store.getFusionDir() });
|
||||
await defaultAgentStore.init();
|
||||
createSSE(store, store.getMissionStore(), aiSessionStore, store.getPluginStore(), undefined, defaultAgentStore)(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -368,15 +372,24 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
// attach to the same EventEmitter instance that the engine writes to,
|
||||
// rather than a separate store created by getOrCreateProjectStore.
|
||||
let scopedStore: TaskStore;
|
||||
let agentStore;
|
||||
if (engineManager) {
|
||||
const engine = engineManager.getEngine(projectId);
|
||||
scopedStore = engine?.getTaskStore() ?? await getOrCreateProjectStore(projectId);
|
||||
// Use the engine's AgentStore if available
|
||||
agentStore = engine?.getAgentStore();
|
||||
} else {
|
||||
scopedStore = await getOrCreateProjectStore(projectId);
|
||||
}
|
||||
// Fallback: create AgentStore if engine doesn't have one
|
||||
if (!agentStore) {
|
||||
const { AgentStore: AgentStoreClass } = await import("@fusion/core");
|
||||
agentStore = new AgentStoreClass({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
}
|
||||
createSSE(scopedStore, scopedStore.getMissionStore(), aiSessionStore, scopedStore.getPluginStore(), {
|
||||
projectId,
|
||||
})(req, res);
|
||||
}, agentStore)(req, res);
|
||||
} catch (err: unknown) {
|
||||
sendErrorResponse(res, 500, err instanceof Error ? err.message : "Failed to open project event stream");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { TaskStore, MissionStore, PluginStore, PluginInstallation, PluginState } from "@fusion/core";
|
||||
import type { TaskStore, MissionStore, PluginStore, PluginInstallation, PluginState, AgentStore } from "@fusion/core";
|
||||
import type { AiSessionStore } from "./ai-session-store.js";
|
||||
|
||||
let activeConnections = 0;
|
||||
@@ -172,6 +172,7 @@ export function createSSE(
|
||||
aiSessionStore?: AiSessionStore,
|
||||
pluginStore?: PluginStore,
|
||||
options?: CreateSSEOptions,
|
||||
agentStore?: AgentStore,
|
||||
) {
|
||||
const { projectId } = options ?? {};
|
||||
|
||||
@@ -324,6 +325,23 @@ export function createSSE(
|
||||
send(`event: plugin:lifecycle\ndata: ${JSON.stringify(payload)}\n\n`);
|
||||
};
|
||||
|
||||
// --- Agent lifecycle event handlers ---
|
||||
const onAgentCreated = (agent: any) => {
|
||||
send(`event: agent:created\ndata: ${JSON.stringify(agent)}\n\n`);
|
||||
};
|
||||
|
||||
const onAgentUpdated = (agent: any) => {
|
||||
send(`event: agent:updated\ndata: ${JSON.stringify(agent)}\n\n`);
|
||||
};
|
||||
|
||||
const onAgentDeleted = (agentId: string) => {
|
||||
send(`event: agent:deleted\ndata: ${JSON.stringify({ id: agentId })}\n\n`);
|
||||
};
|
||||
|
||||
const onAgentStateChanged = (agentId: string, fromState: string, toState: string) => {
|
||||
send(`event: agent:stateChanged\ndata: ${JSON.stringify({ id: agentId, from: fromState, to: toState })}\n\n`);
|
||||
};
|
||||
|
||||
// --- Cleanup (all handlers are defined above, safe to reference) ---
|
||||
|
||||
let cleaned = false;
|
||||
@@ -372,6 +390,12 @@ export function createSSE(
|
||||
pluginStore.off("plugin:disabled", onPluginDisabled);
|
||||
pluginStore.off("plugin:stateChanged", onPluginStateChanged);
|
||||
}
|
||||
if (agentStore) {
|
||||
agentStore.off("agent:created", onAgentCreated);
|
||||
agentStore.off("agent:updated", onAgentUpdated);
|
||||
agentStore.off("agent:deleted", onAgentDeleted);
|
||||
agentStore.off("agent:stateChanged", onAgentStateChanged);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Subscribe ---
|
||||
@@ -420,6 +444,13 @@ export function createSSE(
|
||||
pluginStore.on("plugin:stateChanged", onPluginStateChanged);
|
||||
}
|
||||
|
||||
if (agentStore) {
|
||||
agentStore.on("agent:created", onAgentCreated);
|
||||
agentStore.on("agent:updated", onAgentUpdated);
|
||||
agentStore.on("agent:deleted", onAgentDeleted);
|
||||
agentStore.on("agent:stateChanged", onAgentStateChanged);
|
||||
}
|
||||
|
||||
// Heartbeat every 30s to keep connection alive.
|
||||
// Sent as a named event so the client's EventSource can detect it
|
||||
// (SSE comments starting with ":" are silently consumed and never
|
||||
|
||||
@@ -263,6 +263,11 @@ export class ProjectEngine {
|
||||
return this.runtime.getTaskStore();
|
||||
}
|
||||
|
||||
/** Get the AgentStore (if initialized). Returns undefined before start(). */
|
||||
getAgentStore(): import("@fusion/core").AgentStore | undefined {
|
||||
return this.runtime.getAgentStore();
|
||||
}
|
||||
|
||||
/** Get the HeartbeatMonitor (if initialized). */
|
||||
getHeartbeatMonitor() {
|
||||
return this.runtime.getHeartbeatMonitor();
|
||||
|
||||
@@ -710,6 +710,14 @@ export class InProcessRuntime
|
||||
return this.taskStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the AgentStore instance (if initialized).
|
||||
* Returns undefined before start() or if init fails.
|
||||
*/
|
||||
getAgentStore(): import("@fusion/core").AgentStore | undefined {
|
||||
return this.agentStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the project's Scheduler instance.
|
||||
* @throws Error if runtime has not been started
|
||||
|
||||
Reference in New Issue
Block a user