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:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
112
packages/dashboard/src/server.events.test.ts
Normal file
112
packages/dashboard/src/server.events.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
Reference in New Issue
Block a user