feat(FN-1412): add plugin lifecycle event relay over dashboard SSE

- Add PluginLifecycleEvent type and relay infrastructure in sse.ts
- Emit plugin lifecycle events (install, uninstall, enable, disable) via dashboard SSE
- Wire up plugin events in server.ts to broadcast to connected clients
- Add comprehensive tests for SSE plugin event relay and server integration
- Add .fusion/memory.md with plugin lifecycle documentation
This commit is contained in:
gsxdsm
2026-04-10 03:15:45 -07:00
parent a80595e3c1
commit 34f780e63b
5 changed files with 578 additions and 17 deletions

View File

@@ -32,6 +32,47 @@ function createMockRequest() {
return emitter as unknown as Request;
}
/**
* Extract and parse the JSON data from an SSE message chunk.
* SSE format: "event: event-name\ndata: {...json...}\n\n"
* The regex needs to handle multiline JSON (e.g., with \n in strings).
*/
function extractSSEPayload(sseMsg: string): any {
// Match everything between "data: " and the final "\n\n"
const dataMatch = sseMsg.match(/data: ([\s\S]*?)\n\n/);
if (!dataMatch) {
return {};
}
return JSON.parse(dataMatch[1]);
}
/** Sample plugin installation for testing */
function createMockPlugin(overrides: Partial<{
id: string;
enabled: boolean;
state: string;
error?: string;
settings: Record<string, unknown>;
}> = {}) {
return {
id: overrides.id ?? "test-plugin",
name: "Test Plugin",
version: "1.0.0",
description: "A test plugin",
author: "Test Author",
homepage: "https://example.com",
path: "/path/to/plugin",
enabled: overrides.enabled ?? true,
state: overrides.state ?? "installed",
settings: overrides.settings ?? {},
settingsSchema: undefined,
error: overrides.error,
dependencies: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
describe("createSSE", () => {
let store: ReturnType<typeof createMockStore>;
@@ -210,4 +251,241 @@ describe("createSSE", () => {
req2.emit("close");
expect(getActiveSSEConnections()).toBe(initial);
});
// ── Plugin Lifecycle Event Tests ─────────────────────────────────────────────
describe("plugin lifecycle events", () => {
it("emits plugin:lifecycle event for plugin:registered (installing transition)", () => {
const pluginStore = createMockStore();
const req = createMockRequest();
const { res, chunks } = createMockResponse();
createSSE(store, undefined, undefined, pluginStore)(req, res);
const plugin = createMockPlugin({ id: "my-plugin", state: "installed" });
pluginStore.emit("plugin:registered", plugin);
const sseMsg = chunks.find((c) => c.includes("event: plugin:lifecycle"));
expect(sseMsg).toBeDefined();
expect(sseMsg).toContain("plugin:lifecycle");
// Parse the payload
const payload = extractSSEPayload(sseMsg!);
expect(payload.pluginId).toBe("my-plugin");
expect(payload.transition).toBe("installing");
expect(payload.sourceEvent).toBe("plugin:registered");
expect(payload.timestamp).toBeDefined();
expect(payload.enabled).toBe(true);
expect(payload.state).toBe("installed");
expect(payload.version).toBe("1.0.0");
expect(payload.settings).toEqual({});
});
it("emits plugin:lifecycle event for plugin:enabled (enabled transition)", () => {
const pluginStore = createMockStore();
const req = createMockRequest();
const { res, chunks } = createMockResponse();
createSSE(store, undefined, undefined, pluginStore)(req, res);
const plugin = createMockPlugin({ id: "enabled-plugin", enabled: true, state: "started" });
pluginStore.emit("plugin:enabled", plugin);
const sseMsg = chunks.find((c) => c.includes("event: plugin:lifecycle"));
expect(sseMsg).toBeDefined();
const payload = extractSSEPayload(sseMsg!);
expect(payload.pluginId).toBe("enabled-plugin");
expect(payload.transition).toBe("enabled");
expect(payload.sourceEvent).toBe("plugin:enabled");
expect(payload.enabled).toBe(true);
});
it("emits plugin:lifecycle event for plugin:disabled (disabled transition)", () => {
const pluginStore = createMockStore();
const req = createMockRequest();
const { res, chunks } = createMockResponse();
createSSE(store, undefined, undefined, pluginStore)(req, res);
const plugin = createMockPlugin({ id: "disabled-plugin", enabled: false, state: "stopped" });
pluginStore.emit("plugin:disabled", plugin);
const sseMsg = chunks.find((c) => c.includes("event: plugin:lifecycle"));
expect(sseMsg).toBeDefined();
const payload = extractSSEPayload(sseMsg!);
expect(payload.pluginId).toBe("disabled-plugin");
expect(payload.transition).toBe("disabled");
expect(payload.sourceEvent).toBe("plugin:disabled");
expect(payload.enabled).toBe(false);
});
it("emits plugin:lifecycle event for plugin:stateChanged with error state (error transition)", () => {
const pluginStore = createMockStore();
const req = createMockRequest();
const { res, chunks } = createMockResponse();
createSSE(store, undefined, undefined, pluginStore)(req, res);
const plugin = createMockPlugin({
id: "error-plugin",
state: "error",
error: "Failed to load: missing dependency",
});
pluginStore.emit("plugin:stateChanged", plugin);
const sseMsg = chunks.find((c) => c.includes("event: plugin:lifecycle"));
expect(sseMsg).toBeDefined();
const payload = extractSSEPayload(sseMsg!);
expect(payload.pluginId).toBe("error-plugin");
expect(payload.transition).toBe("error");
expect(payload.sourceEvent).toBe("plugin:stateChanged");
expect(payload.state).toBe("error");
expect(payload.error).toBe("Failed to load: missing dependency");
});
it("emits plugin:lifecycle event for plugin:unregistered (uninstalled transition)", () => {
const pluginStore = createMockStore();
const req = createMockRequest();
const { res, chunks } = createMockResponse();
createSSE(store, undefined, undefined, pluginStore)(req, res);
const plugin = createMockPlugin({ id: "uninstalled-plugin" });
pluginStore.emit("plugin:unregistered", plugin);
const sseMsg = chunks.find((c) => c.includes("event: plugin:lifecycle"));
expect(sseMsg).toBeDefined();
const payload = extractSSEPayload(sseMsg!);
expect(payload.pluginId).toBe("uninstalled-plugin");
expect(payload.transition).toBe("uninstalled");
expect(payload.sourceEvent).toBe("plugin:unregistered");
});
it("emits plugin:lifecycle event for plugin:updated (settings-updated transition)", () => {
const pluginStore = createMockStore();
const req = createMockRequest();
const { res, chunks } = createMockResponse();
createSSE(store, undefined, undefined, pluginStore)(req, res);
const plugin = createMockPlugin({
id: "settings-plugin",
settings: { apiKey: "secret123", debugMode: true },
});
pluginStore.emit("plugin:updated", plugin);
const sseMsg = chunks.find((c) => c.includes("event: plugin:lifecycle"));
expect(sseMsg).toBeDefined();
const payload = extractSSEPayload(sseMsg!);
expect(payload.pluginId).toBe("settings-plugin");
expect(payload.transition).toBe("settings-updated");
expect(payload.sourceEvent).toBe("plugin:updated");
expect(payload.settings).toEqual({ apiKey: "secret123", debugMode: true });
});
it("includes projectId in payload when options.projectId is provided", () => {
const pluginStore = createMockStore();
const req = createMockRequest();
const { res, chunks } = createMockResponse();
createSSE(store, undefined, undefined, pluginStore, { projectId: "proj_abc123" })(req, res);
const plugin = createMockPlugin({ id: "scoped-plugin" });
pluginStore.emit("plugin:registered", plugin);
const sseMsg = chunks.find((c) => c.includes("event: plugin:lifecycle"));
expect(sseMsg).toBeDefined();
const payload = extractSSEPayload(sseMsg!);
expect(payload.projectId).toBe("proj_abc123");
});
it("does not include projectId in payload for default streams", () => {
const pluginStore = createMockStore();
const req = createMockRequest();
const { res, chunks } = createMockResponse();
createSSE(store, undefined, undefined, pluginStore)(req, res);
const plugin = createMockPlugin({ id: "default-plugin" });
pluginStore.emit("plugin:registered", plugin);
const sseMsg = chunks.find((c) => c.includes("event: plugin:lifecycle"));
expect(sseMsg).toBeDefined();
const payload = extractSSEPayload(sseMsg!);
expect(payload.projectId).toBeUndefined();
});
it("cleans up plugin listeners when client disconnects", () => {
const pluginStore = createMockStore();
const req = createMockRequest();
const { res } = createMockResponse();
createSSE(store, undefined, undefined, pluginStore)(req, res);
// Verify listeners are attached
expect(pluginStore.listenerCount("plugin:registered")).toBe(1);
expect(pluginStore.listenerCount("plugin:unregistered")).toBe(1);
expect(pluginStore.listenerCount("plugin:updated")).toBe(1);
expect(pluginStore.listenerCount("plugin:enabled")).toBe(1);
expect(pluginStore.listenerCount("plugin:disabled")).toBe(1);
expect(pluginStore.listenerCount("plugin:stateChanged")).toBe(1);
req.emit("close");
// All plugin listeners should be removed
expect(pluginStore.listenerCount("plugin:registered")).toBe(0);
expect(pluginStore.listenerCount("plugin:unregistered")).toBe(0);
expect(pluginStore.listenerCount("plugin:updated")).toBe(0);
expect(pluginStore.listenerCount("plugin:enabled")).toBe(0);
expect(pluginStore.listenerCount("plugin:disabled")).toBe(0);
expect(pluginStore.listenerCount("plugin:stateChanged")).toBe(0);
});
it("stops writing and cleans up plugin listeners when res.write throws", () => {
const pluginStore = createMockStore();
const req = createMockRequest();
const { res } = createMockResponse();
createSSE(store, undefined, undefined, pluginStore)(req, res);
// Make write throw on next call
(res.write as any).mockImplementation(() => {
throw new Error("Socket closed");
});
// Emit a plugin event — should not throw
const plugin = createMockPlugin({ id: "cleanup-plugin" });
expect(() => pluginStore.emit("plugin:registered", plugin)).not.toThrow();
// All plugin listeners should be removed
expect(pluginStore.listenerCount("plugin:registered")).toBe(0);
expect(pluginStore.listenerCount("plugin:enabled")).toBe(0);
});
it("handles multiple plugin lifecycle events in sequence", () => {
const pluginStore = createMockStore();
const req = createMockRequest();
const { res, chunks } = createMockResponse();
createSSE(store, undefined, undefined, pluginStore)(req, res);
// Simulate a plugin lifecycle: install → enable → update settings
const plugin1 = createMockPlugin({ id: "multi-plugin", state: "installed" });
pluginStore.emit("plugin:registered", plugin1);
const plugin2 = createMockPlugin({ id: "multi-plugin", enabled: true, state: "started" });
pluginStore.emit("plugin:enabled", plugin2);
const plugin3 = createMockPlugin({ id: "multi-plugin", settings: { key: "value" } });
pluginStore.emit("plugin:updated", plugin3);
const lifecycleEvents = chunks.filter((c) => c.includes("event: plugin:lifecycle"));
expect(lifecycleEvents.length).toBe(3);
const payload1 = extractSSEPayload(lifecycleEvents[0]);
expect(payload1.transition).toBe("installing");
const payload2 = extractSSEPayload(lifecycleEvents[1]);
expect(payload2.transition).toBe("enabled");
const payload3 = extractSSEPayload(lifecycleEvents[2]);
expect(payload3.transition).toBe("settings-updated");
});
});
});

View File

@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import express from "express";
import { createServer } from "./server.js";
import type { TaskStore, PluginStore } from "@fusion/core";
import { get as performGet } from "./test-request.js";
// Mock terminal-service before any imports that use it
vi.mock("./terminal-service.js", () => {
const mockTerminalService = {
getSession: vi.fn(),
getScrollbackAndClearPending: vi.fn().mockReturnValue(null),
onData: vi.fn().mockReturnValue(() => {}),
onExit: vi.fn().mockReturnValue(() => {}),
write: vi.fn(),
resize: vi.fn(),
evictStaleSessions: vi.fn().mockReturnValue(0),
};
return {
getTerminalService: vi.fn(() => mockTerminalService),
STALE_SESSION_THRESHOLD_MS: 300_000,
__mockTerminalService: mockTerminalService,
};
});
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
const mockMissionStore = {
listMissions: vi.fn().mockReturnValue([]),
createMission: vi.fn(),
getMissionWithHierarchy: vi.fn(),
updateMission: vi.fn(),
getMission: vi.fn(),
deleteMission: vi.fn(),
listMilestonesByMission: vi.fn().mockReturnValue([]),
createMilestone: vi.fn(),
updateMilestone: vi.fn(),
getMilestone: vi.fn(),
deleteMilestone: vi.fn(),
listTasksByMilestone: vi.fn().mockReturnValue([]),
createMissionTask: vi.fn(),
updateMissionTask: vi.fn(),
getMissionTask: vi.fn(),
deleteMissionTask: vi.fn(),
on: vi.fn(),
off: vi.fn(),
};
const mockPluginStore = {
on: vi.fn(),
off: vi.fn(),
} as unknown as PluginStore;
return {
getTask: vi.fn(),
listTasks: vi.fn().mockResolvedValue([]),
createTask: vi.fn(),
moveTask: vi.fn(),
updateTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn(),
logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
addSteeringComment: vi.fn(),
updatePrInfo: vi.fn().mockResolvedValue(undefined),
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
getFusionDir: vi.fn().mockReturnValue("/fake/fusion"),
getDatabase: vi.fn().mockReturnValue({
exec: vi.fn(),
prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }),
}),
getMissionStore: vi.fn().mockReturnValue(mockMissionStore),
getPluginStore: vi.fn().mockReturnValue(mockPluginStore),
on: vi.fn(),
off: vi.fn(),
...overrides,
} as unknown as TaskStore;
}
describe("server events endpoint integration", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("creates server with store that has getPluginStore method", () => {
const store = createMockStore();
const app = createServer(store);
// Verify the store has getPluginStore
expect(typeof store.getPluginStore).toBe("function");
// Verify the store has getMissionStore (used by createSSE)
expect(typeof store.getMissionStore).toBe("function");
});
it("creates server that handles SSE endpoint without projectId", () => {
const store = createMockStore();
const app = createServer(store);
// Just verify the server was created without error
expect(app).toBeDefined();
});
});

View File

@@ -218,7 +218,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
app.get("/api/events", rateLimit(RATE_LIMITS.sse), async (req, res) => {
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;
if (!projectId) {
createSSE(store, store.getMissionStore(), aiSessionStore)(req, res);
createSSE(store, store.getMissionStore(), aiSessionStore, store.getPluginStore())(req, res);
return;
}
@@ -226,7 +226,9 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
// Use the shared project-store resolver so SSE listeners attach to
// the same EventEmitter used by project-scoped task API routes.
const scopedStore = await getOrCreateProjectStore(projectId);
createSSE(scopedStore, scopedStore.getMissionStore(), aiSessionStore)(req, res);
createSSE(scopedStore, scopedStore.getMissionStore(), aiSessionStore, scopedStore.getPluginStore(), {
projectId,
})(req, res);
} catch (err: any) {
sendErrorResponse(res, 500, err.message ?? "Failed to open project event stream");
}

View File

@@ -1,5 +1,5 @@
import type { Request, Response } from "express";
import type { TaskStore, MissionStore, PluginStore } from "@fusion/core";
import type { TaskStore, MissionStore, PluginStore, PluginInstallation, PluginState } from "@fusion/core";
import type { AiSessionStore } from "./ai-session-store.js";
let activeConnections = 0;
@@ -24,7 +24,123 @@ function safeWrite(res: Response, data: string): boolean {
}
}
export function createSSE(store: TaskStore, missionStore?: MissionStore, aiSessionStore?: AiSessionStore, pluginStore?: PluginStore) {
/**
* Normalized plugin lifecycle transition types.
* These are the unified set of transitions that the SSE stream emits.
*/
export type PluginLifecycleTransition =
| "installing"
| "enabled"
| "disabled"
| "error"
| "uninstalled"
| "settings-updated";
/**
* Normalized plugin lifecycle payload emitted via SSE.
* This is the stable contract the UI can reconcile.
*/
export interface PluginLifecyclePayload {
/** Plugin identifier */
pluginId: string;
/** Normalized transition type */
transition: PluginLifecycleTransition;
/** Underlying store/runtime event that triggered this transition */
sourceEvent: string;
/** ISO-8601 timestamp of the event */
timestamp: string;
/** Project ID when stream is project-scoped (omitted for default streams) */
projectId?: string;
/** Whether the plugin is currently enabled */
enabled: boolean;
/** Current plugin state */
state: PluginState;
/** Plugin version */
version: string;
/** Plugin settings snapshot */
settings: Record<string, unknown>;
/** Error message (only present when state is "error") */
error?: string;
}
/**
* Map source event names to normalized plugin lifecycle transitions.
* This ensures equivalent source events always map to the same transition value.
*/
function mapSourceEventToTransition(
sourceEvent: string,
plugin: PluginInstallation,
previousState?: PluginState,
): PluginLifecycleTransition {
switch (sourceEvent) {
case "plugin:registered":
return "installing";
case "plugin:enabled":
return "enabled";
case "plugin:disabled":
return "disabled";
case "plugin:stateChanged":
// If the new state is "error", emit the "error" transition
if (plugin.state === "error") {
return "error";
}
// For other state changes (started, stopped), we don't emit a dedicated transition
// but still emit the lifecycle event for observability
return "error"; // Map to "error" as a fallback for non-standard state transitions
case "plugin:unregistered":
return "uninstalled";
case "plugin:updated":
// Check if this looks like a settings update
// (we emit settings-updated for any update, as the UI can diff if needed)
return "settings-updated";
default:
// Unknown events map to error for safety
return "error";
}
}
/**
* Create a normalized plugin lifecycle payload from a source event.
*/
function createPluginLifecyclePayload(
sourceEvent: string,
plugin: PluginInstallation,
projectId?: string,
): PluginLifecyclePayload {
return {
pluginId: plugin.id,
transition: mapSourceEventToTransition(sourceEvent, plugin),
sourceEvent,
timestamp: new Date().toISOString(),
projectId,
enabled: plugin.enabled,
state: plugin.state,
version: plugin.version,
settings: plugin.settings,
error: plugin.error,
};
}
export interface CreateSSEOptions {
/** Project ID for project-scoped streams (enables scope attribution) */
projectId?: string;
}
export function createSSE(
store: TaskStore,
missionStore?: MissionStore,
aiSessionStore?: AiSessionStore,
pluginStore?: PluginStore,
options?: CreateSSEOptions,
) {
const { projectId } = options ?? {};
return (_req: Request, res: Response) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
@@ -113,24 +229,39 @@ export function createSSE(store: TaskStore, missionStore?: MissionStore, aiSessi
send(`event: ai_session:deleted\ndata: ${JSON.stringify(data)}\n\n`);
};
// Plugin event handlers
const onPluginRegistered = (data: any) => {
send(`event: plugin:registered\ndata: ${JSON.stringify(data)}\n\n`);
// --- Unified plugin lifecycle handler ---
// Instead of emitting individual plugin events, we normalize all plugin
// lifecycle changes into a single `plugin:lifecycle` SSE event with
// a deterministic payload contract.
const onPluginRegistered = (plugin: PluginInstallation) => {
const payload = createPluginLifecyclePayload("plugin:registered", plugin, projectId);
send(`event: plugin:lifecycle\ndata: ${JSON.stringify(payload)}\n\n`);
};
const onPluginUnregistered = (data: any) => {
send(`event: plugin:unregistered\ndata: ${JSON.stringify(data)}\n\n`);
const onPluginUnregistered = (plugin: PluginInstallation) => {
const payload = createPluginLifecyclePayload("plugin:unregistered", plugin, projectId);
send(`event: plugin:lifecycle\ndata: ${JSON.stringify(payload)}\n\n`);
};
const onPluginUpdated = (data: any) => {
send(`event: plugin:updated\ndata: ${JSON.stringify(data)}\n\n`);
const onPluginUpdated = (plugin: PluginInstallation) => {
const payload = createPluginLifecyclePayload("plugin:updated", plugin, projectId);
send(`event: plugin:lifecycle\ndata: ${JSON.stringify(payload)}\n\n`);
};
const onPluginEnabled = (data: any) => {
send(`event: plugin:enabled\ndata: ${JSON.stringify(data)}\n\n`);
const onPluginEnabled = (plugin: PluginInstallation) => {
const payload = createPluginLifecyclePayload("plugin:enabled", plugin, projectId);
send(`event: plugin:lifecycle\ndata: ${JSON.stringify(payload)}\n\n`);
};
const onPluginDisabled = (data: any) => {
send(`event: plugin:disabled\ndata: ${JSON.stringify(data)}\n\n`);
const onPluginDisabled = (plugin: PluginInstallation) => {
const payload = createPluginLifecyclePayload("plugin:disabled", plugin, projectId);
send(`event: plugin:lifecycle\ndata: ${JSON.stringify(payload)}\n\n`);
};
const onPluginStateChanged = (data: any) => {
send(`event: plugin:stateChanged\ndata: ${JSON.stringify(data)}\n\n`);
const onPluginStateChanged = (plugin: PluginInstallation) => {
const payload = createPluginLifecyclePayload("plugin:stateChanged", plugin, projectId);
send(`event: plugin:lifecycle\ndata: ${JSON.stringify(payload)}\n\n`);
};
// --- Cleanup (all handlers are defined above, safe to reference) ---