feat(FN-1914): merge fusion/fn-1914

This commit is contained in:
gsxdsm
2026-04-16 07:53:31 -07:00
parent 0d507091b2
commit 68b1651e0b
11 changed files with 745 additions and 6 deletions

View File

@@ -107,6 +107,7 @@ export type {
PluginToolResult,
PluginRouteDefinition,
PluginRouteMethod,
PluginUiSlotDefinition,
PluginContext,
PluginLogger,
FusionPlugin,

View File

@@ -813,6 +813,182 @@ describe("PluginLoader", () => {
});
});
// ── getPluginUiSlots ───────────────────────────────────────────────
describe("getPluginUiSlots", () => {
it("returns empty array when no plugins loaded", async () => {
await pluginStore.init();
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
const slots = loader.getPluginUiSlots();
expect(slots).toEqual([]);
});
it("returns empty array when plugins have no uiSlots", async () => {
await pluginStore.init();
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
// Manually add plugin without uiSlots
(loader as any).plugins.set("no-ui-slots", {
manifest: makeManifest({ id: "no-ui-slots" }),
state: "started",
hooks: {},
tools: [],
routes: [],
} as FusionPlugin);
const slots = loader.getPluginUiSlots();
expect(slots).toEqual([]);
});
it("returns aggregated slots from single plugin", async () => {
await pluginStore.init();
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
// Manually add plugin with uiSlots
(loader as any).plugins.set("slots-a", {
manifest: makeManifest({ id: "slots-a" }),
state: "started",
hooks: {},
tools: [],
routes: [],
uiSlots: [
{
slotId: "task-detail-tab",
label: "Task Details",
componentPath: "./components/TaskDetailTab.js",
},
],
} as FusionPlugin);
const slots = loader.getPluginUiSlots();
expect(slots).toHaveLength(1);
expect(slots[0].pluginId).toBe("slots-a");
expect(slots[0].slot.slotId).toBe("task-detail-tab");
expect(slots[0].slot.label).toBe("Task Details");
expect(slots[0].slot.componentPath).toBe("./components/TaskDetailTab.js");
});
it("returns aggregated slots from multiple plugins", async () => {
await pluginStore.init();
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
// Manually add plugins with uiSlots
(loader as any).plugins.set("slots-a", {
manifest: makeManifest({ id: "slots-a" }),
state: "started",
hooks: {},
tools: [],
routes: [],
uiSlots: [
{
slotId: "task-detail-tab",
label: "Task Details",
componentPath: "./components/TaskDetailTab.js",
},
],
} as FusionPlugin);
(loader as any).plugins.set("slots-b", {
manifest: makeManifest({ id: "slots-b" }),
state: "started",
hooks: {},
tools: [],
routes: [],
uiSlots: [
{
slotId: "header-action",
label: "Header Action",
icon: "Plus",
componentPath: "./components/HeaderAction.js",
},
{
slotId: "settings-section",
label: "Settings",
componentPath: "./components/SettingsSection.js",
},
],
} as FusionPlugin);
const slots = loader.getPluginUiSlots();
expect(slots).toHaveLength(3);
expect(slots.find((s) => s.pluginId === "slots-a")?.slot.slotId).toBe(
"task-detail-tab",
);
expect(slots.find((s) => s.pluginId === "slots-b")?.slot.slotId).toBe(
"header-action",
);
expect(slots.filter((s) => s.pluginId === "slots-b")).toHaveLength(2);
});
it("each slot includes correct pluginId", async () => {
await pluginStore.init();
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
// Manually add plugins with overlapping slotIds (different plugins)
(loader as any).plugins.set("plugin-x", {
manifest: makeManifest({ id: "plugin-x" }),
state: "started",
hooks: {},
tools: [],
routes: [],
uiSlots: [
{
slotId: "custom-tab",
label: "Custom Tab",
componentPath: "./components/CustomTab.js",
},
],
} as FusionPlugin);
(loader as any).plugins.set("plugin-y", {
manifest: makeManifest({ id: "plugin-y" }),
state: "started",
hooks: {},
tools: [],
routes: [],
uiSlots: [
{
slotId: "custom-tab",
label: "Custom Tab Y",
componentPath: "./components/CustomTabY.js",
},
],
} as FusionPlugin);
const slots = loader.getPluginUiSlots();
// Both plugins can have slots with the same slotId
const pluginXSlot = slots.find((s) => s.pluginId === "plugin-x");
const pluginYSlot = slots.find((s) => s.pluginId === "plugin-y");
expect(pluginXSlot?.slot.slotId).toBe("custom-tab");
expect(pluginXSlot?.slot.label).toBe("Custom Tab");
expect(pluginYSlot?.slot.slotId).toBe("custom-tab");
expect(pluginYSlot?.slot.label).toBe("Custom Tab Y");
});
});
// ── getLoadedPlugins ───────────────────────────────────────────────
describe("getLoadedPlugins", () => {

View File

@@ -19,6 +19,7 @@ import type {
PluginLogger,
PluginToolDefinition,
PluginRouteDefinition,
PluginUiSlotDefinition,
PluginState,
PluginInstallation,
} from "./plugin-types.js";
@@ -729,6 +730,21 @@ export class PluginLoader extends EventEmitter<{
return routes;
}
/**
* Get all UI slot definitions from loaded plugins.
*/
getPluginUiSlots(): Array<{ pluginId: string; slot: PluginUiSlotDefinition }> {
const slots: Array<{ pluginId: string; slot: PluginUiSlotDefinition }> = [];
for (const [pluginId, plugin] of this.plugins) {
if (plugin.uiSlots) {
for (const slot of plugin.uiSlots) {
slots.push({ pluginId, slot });
}
}
}
return slots;
}
/**
* Get all loaded plugin instances.
*/

View File

@@ -463,3 +463,106 @@ describe("validatePluginManifest", () => {
});
});
});
// ── PluginUiSlotDefinition ─────────────────────────────────────────────
describe("PluginUiSlotDefinition", () => {
it("accepts a valid PluginUiSlotDefinition with all fields", () => {
const slot = {
slotId: "task-detail-tab",
label: "Task Details",
icon: "FileText",
componentPath: "./components/TaskDetailTab.js",
};
expect(slot.slotId).toBe("task-detail-tab");
expect(slot.label).toBe("Task Details");
expect(slot.icon).toBe("FileText");
expect(slot.componentPath).toBe("./components/TaskDetailTab.js");
});
it("accepts a valid PluginUiSlotDefinition without optional icon field", () => {
const slot = {
slotId: "header-action",
label: "Header Action",
componentPath: "./components/HeaderAction.js",
};
expect(slot.slotId).toBe("header-action");
expect(slot.label).toBe("Header Action");
expect(slot.componentPath).toBe("./components/HeaderAction.js");
// icon is optional, so it should be undefined
expect((slot as any).icon).toBeUndefined();
});
it("requires slotId field", () => {
const slot = {
label: "Some Label",
componentPath: "./components/Test.js",
};
// TypeScript would catch this at compile time, but at runtime we verify the structure
expect((slot as any).slotId).toBeUndefined();
});
it("requires label field", () => {
const slot = {
slotId: "some-slot",
componentPath: "./components/Test.js",
};
expect((slot as any).label).toBeUndefined();
});
it("requires componentPath field", () => {
const slot = {
slotId: "some-slot",
label: "Some Label",
};
expect((slot as any).componentPath).toBeUndefined();
});
});
// ── FusionPlugin with uiSlots ──────────────────────────────────────────
describe("FusionPlugin with uiSlots", () => {
it("accepts a FusionPlugin with uiSlots array", () => {
const plugin = {
manifest: { id: "test-plugin", name: "Test Plugin", version: "1.0.0" },
state: "started" as const,
hooks: {},
tools: [],
routes: [],
uiSlots: [
{
slotId: "task-detail-tab",
label: "Task Details",
componentPath: "./components/TaskDetailTab.js",
},
{
slotId: "header-action",
label: "Header Action",
icon: "Plus",
componentPath: "./components/HeaderAction.js",
},
],
};
expect(plugin.uiSlots).toHaveLength(2);
expect(plugin.uiSlots![0].slotId).toBe("task-detail-tab");
expect(plugin.uiSlots![1].icon).toBe("Plus");
});
it("accepts a FusionPlugin without uiSlots field", () => {
const plugin = {
manifest: { id: "test-plugin", name: "Test Plugin", version: "1.0.0" },
state: "started" as const,
hooks: {},
tools: [],
routes: [],
};
expect((plugin as any).uiSlots).toBeUndefined();
});
});

View File

@@ -142,6 +142,28 @@ export interface PluginRouteDefinition {
description?: string;
}
// ── Plugin UI Slots ─────────────────────────────────────────────────
/**
* UI slot definition for plugin-provided dashboard components.
* Each slot represents a mount point where a plugin can render UI.
*/
export interface PluginUiSlotDefinition {
/** Unique slot identifier (e.g., "task-detail-tab", "header-action", "settings-section") */
slotId: string;
/** Human-readable label for the UI slot */
label: string;
/** Optional icon name (lucide-react icon name or custom icon identifier) */
icon?: string;
/**
* Path to the JS module that exports the component.
* This should be a web component or a function component descriptor
* that the dashboard can render in the slot.
* Path is relative to the plugin's root directory.
*/
componentPath: string;
}
// ── Fusion Plugin ────────────────────────────────────────────────────
export type PluginState = "installed" | "started" | "stopped" | "error";
@@ -162,6 +184,7 @@ export interface FusionPlugin {
};
tools?: PluginToolDefinition[];
routes?: PluginRouteDefinition[];
uiSlots?: PluginUiSlotDefinition[];
}
// ── Plugin Installation ───────────────────────────────────────────────

View File

@@ -87,6 +87,7 @@ function createMockPluginLoader(overrides: Partial<PluginLoader> = {}): PluginLo
getLoadedPlugins: vi.fn().mockReturnValue([]),
getPluginTools: vi.fn().mockReturnValue([]),
getPluginRoutes: vi.fn().mockReturnValue([]),
getPluginUiSlots: vi.fn().mockReturnValue([]),
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
stopAllPlugins: vi.fn().mockResolvedValue(undefined),
invokeHook: vi.fn().mockResolvedValue(undefined),
@@ -657,3 +658,115 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", ()
);
});
});
// ══════════════════════════════════════════════════════════════════
describe("GET /api/plugins/ui-slots", () => {
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;
let store: TaskStore;
beforeEach(() => {
vi.clearAllMocks();
pluginStore = createMockPluginStore();
pluginLoader = createMockPluginLoader();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { pluginStore, pluginLoader }));
return app;
}
it("returns 200 with empty array when no plugins have uiSlots", async () => {
(pluginLoader.getPluginUiSlots as ReturnType<typeof vi.fn>).mockReturnValue([]);
const res = await performGet(buildApp(), "/api/plugins/ui-slots");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("returns 200 with aggregated slots when plugins have uiSlots", async () => {
const mockSlots = [
{
pluginId: "test-plugin",
slot: {
slotId: "task-detail-tab",
label: "Task Details",
componentPath: "./components/TaskDetailTab.js",
},
},
{
pluginId: "test-plugin",
slot: {
slotId: "header-action",
label: "Header Action",
icon: "Plus",
componentPath: "./components/HeaderAction.js",
},
},
];
(pluginLoader.getPluginUiSlots as ReturnType<typeof vi.fn>).mockReturnValue(mockSlots);
const res = await performGet(buildApp(), "/api/plugins/ui-slots");
expect(res.status).toBe(200);
expect(res.body).toEqual(mockSlots);
expect(res.body).toHaveLength(2);
expect(res.body[0].pluginId).toBe("test-plugin");
expect(res.body[0].slot.slotId).toBe("task-detail-tab");
expect(res.body[1].slot.icon).toBe("Plus");
});
it("response shape is Array<{ pluginId: string; slot: PluginUiSlotDefinition }>", async () => {
const mockSlots = [
{
pluginId: "plugin-a",
slot: {
slotId: "custom-slot",
label: "Custom Slot",
componentPath: "./components/CustomSlot.js",
},
},
];
(pluginLoader.getPluginUiSlots as ReturnType<typeof vi.fn>).mockReturnValue(mockSlots);
const res = await performGet(buildApp(), "/api/plugins/ui-slots");
expect(res.status).toBe(200);
// Verify response shape
expect(Array.isArray(res.body)).toBe(true);
expect(res.body[0]).toHaveProperty("pluginId");
expect(typeof res.body[0].pluginId).toBe("string");
expect(res.body[0]).toHaveProperty("slot");
expect(res.body[0].slot).toHaveProperty("slotId");
expect(res.body[0].slot).toHaveProperty("label");
expect(res.body[0].slot).toHaveProperty("componentPath");
});
it("returns empty array when pluginLoader is not available", async () => {
// Build app without pluginLoader
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { pluginStore }));
const res = await performGet(app, "/api/plugins/ui-slots");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("does not conflict with /plugins/:id route", async () => {
// Verify that /plugins/ui-slots doesn't get matched by /plugins/:id
(pluginLoader.getPluginUiSlots as ReturnType<typeof vi.fn>).mockReturnValue([]);
const res = await performGet(buildApp(), "/api/plugins/ui-slots");
// Should return 200, not 404 (which would happen if :id = "ui-slots" matched)
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
});

View File

@@ -12969,6 +12969,16 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
res.json(plugins);
});
/**
* GET /api/plugins/ui-slots
* Get all UI slot definitions from active plugins.
* Returns aggregated array of { pluginId, slot } objects.
*/
router.get("/plugins/ui-slots", async (_req: Request, res: Response) => {
const slots = options?.pluginLoader?.getPluginUiSlots() ?? [];
res.json(slots);
});
/**
* GET /api/plugins/:id
* Get a single plugin by ID.

View File

@@ -31,6 +31,7 @@ describe("PluginRunner", () => {
invokeHook: ReturnType<typeof vi.fn>;
getPluginTools: ReturnType<typeof vi.fn>;
getPluginRoutes: ReturnType<typeof vi.fn>;
getPluginUiSlots: ReturnType<typeof vi.fn>;
getLoadedPlugins: ReturnType<typeof vi.fn>;
getPlugin: ReturnType<typeof vi.fn>;
loadPlugin: ReturnType<typeof vi.fn>;
@@ -70,6 +71,7 @@ describe("PluginRunner", () => {
invokeHook: vi.fn().mockResolvedValue(undefined),
getPluginTools: vi.fn().mockReturnValue([]),
getPluginRoutes: vi.fn().mockReturnValue([]),
getPluginUiSlots: vi.fn().mockReturnValue([]),
getLoadedPlugins: vi.fn().mockReturnValue([]),
getPlugin: vi.fn(),
loadPlugin: vi.fn().mockResolvedValue({}),
@@ -280,6 +282,202 @@ describe("PluginRunner", () => {
});
});
describe("getPluginUiSlots()", () => {
it("should return empty array when no plugins have uiSlots", async () => {
mockPluginLoader.getPluginUiSlots.mockReturnValue([]);
await pluginRunner.init();
const slots = pluginRunner.getPluginUiSlots();
expect(slots).toEqual([]);
});
it("should return cached slots after plugins load", async () => {
const mockSlots = [
{
pluginId: "test-plugin",
slot: {
slotId: "task-detail-tab",
label: "Task Details",
componentPath: "./components/TaskDetailTab.js",
},
},
];
mockPluginLoader.getPluginUiSlots.mockReturnValue(mockSlots);
await pluginRunner.init();
const slots1 = pluginRunner.getPluginUiSlots();
const slots2 = pluginRunner.getPluginUiSlots();
expect(slots1).toEqual(mockSlots);
expect(slots2).toBe(slots1); // Same reference (cached)
});
it("should invalidate cache on plugin:reloaded event", async () => {
const mockSlots = [
{
pluginId: "test-plugin",
slot: {
slotId: "custom-tab",
label: "Custom Tab",
componentPath: "./components/CustomTab.js",
},
},
];
mockPluginLoader.getPluginUiSlots.mockReturnValue(mockSlots);
await pluginRunner.init();
const slots1 = pluginRunner.getPluginUiSlots();
expect(slots1).toEqual(mockSlots);
// Simulate plugin:reloaded event that invalidates cache
const reloadHandler = mockPluginLoader.on.mock.calls.find(
call => call[0] === "plugin:reloaded"
)?.[1];
if (reloadHandler) {
reloadHandler({ pluginId: "test-plugin" });
}
// Next call should rebuild cache
const newSlots = [
{
pluginId: "test-plugin",
slot: {
slotId: "updated-tab",
label: "Updated Tab",
componentPath: "./components/UpdatedTab.js",
},
},
];
mockPluginLoader.getPluginUiSlots.mockReturnValue(newSlots);
const slots2 = pluginRunner.getPluginUiSlots();
expect(slots2).toEqual(newSlots);
expect(mockPluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(2);
});
it("should invalidate cache on plugin:enabled event", async () => {
mockPluginLoader.getPluginUiSlots.mockReturnValue([]);
await pluginRunner.init();
// Get initial slots
pluginRunner.getPluginUiSlots();
// Simulate plugin:enabled event
const enabledHandler = mockPluginStore.on.mock.calls.find(
call => call[0] === "plugin:enabled"
)?.[1];
const newPlugin = {
id: "new-plugin",
name: "New Plugin",
version: "1.0.0",
path: "/test/path",
enabled: true,
state: "stopped" as const,
settings: {},
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
};
if (enabledHandler) {
enabledHandler(newPlugin);
}
// Next call should rebuild cache
pluginRunner.getPluginUiSlots();
expect(mockPluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(2);
});
it("should invalidate cache on plugin:disabled event", async () => {
mockPluginLoader.getPluginUiSlots.mockReturnValue([]);
await pluginRunner.init();
// Get initial slots
pluginRunner.getPluginUiSlots();
// Simulate plugin:disabled event
const disabledHandler = mockPluginStore.on.mock.calls.find(
call => call[0] === "plugin:disabled"
)?.[1];
const plugin = {
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
path: "/test/path",
enabled: true,
state: "stopped" as const,
settings: {},
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
};
if (disabledHandler) {
disabledHandler(plugin);
}
// Next call should rebuild cache
pluginRunner.getPluginUiSlots();
expect(mockPluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(2);
});
it("should invalidate cache on plugin:stateChanged event", async () => {
mockPluginLoader.getPluginUiSlots.mockReturnValue([]);
await pluginRunner.init();
// Get initial slots
pluginRunner.getPluginUiSlots();
// Simulate plugin:stateChanged event
const stateHandler = mockPluginStore.on.mock.calls.find(
call => call[0] === "plugin:stateChanged"
)?.[1];
if (stateHandler) {
stateHandler();
}
// Next call should rebuild cache
pluginRunner.getPluginUiSlots();
expect(mockPluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(2);
});
it("should invalidate cache on plugin:updated event", async () => {
mockPluginLoader.getPluginUiSlots.mockReturnValue([]);
await pluginRunner.init();
// Get initial slots
pluginRunner.getPluginUiSlots();
// Simulate plugin:updated event
const updatedHandler = mockPluginStore.on.mock.calls.find(
call => call[0] === "plugin:updated"
)?.[1];
if (updatedHandler) {
updatedHandler();
}
// Next call should rebuild cache
pluginRunner.getPluginUiSlots();
expect(mockPluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(2);
});
it("should invalidate cache on reloadPlugin()", async () => {
mockPluginLoader.getPluginUiSlots.mockReturnValue([]);
await pluginRunner.init();
// Get initial slots
pluginRunner.getPluginUiSlots();
// Call reloadPlugin
await pluginRunner.reloadPlugin("test-plugin");
// Next call should rebuild cache
pluginRunner.getPluginUiSlots();
expect(mockPluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(2);
});
});
describe("getLoader() / getStore()", () => {
it("should return the plugin loader", () => {
const loader = pluginRunner.getLoader();

View File

@@ -14,6 +14,7 @@ import type {
FusionPlugin,
PluginToolDefinition,
PluginRouteDefinition,
PluginUiSlotDefinition,
PluginContext,
} from "@fusion/core";
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
@@ -51,14 +52,24 @@ interface CachedRoutes {
version: number;
}
/**
* Cached UI slots - rebuilt when plugin state changes
*/
interface CachedUiSlots {
slots: Array<{ pluginId: string; slot: PluginUiSlotDefinition }>;
version: number;
}
const DEFAULT_HOOK_TIMEOUT_MS = 5000;
export class PluginRunner {
private readonly log = createLogger("plugin-runner");
private cachedTools: CachedTools | null = null;
private cachedRoutes: CachedRoutes | null = null;
private cachedUiSlots: CachedUiSlots | null = null;
private toolsCacheVersion = 0;
private routesCacheVersion = 0;
private uiSlotsCacheVersion = 0;
private hookTimeoutMs: number;
// Event handler references for cleanup
@@ -114,6 +125,7 @@ export class PluginRunner {
// Build initial caches
this.invalidateToolsCache();
this.invalidateRoutesCache();
this.invalidateUiSlotsCache();
}
/**
@@ -182,6 +194,20 @@ export class PluginRunner {
return this.cachedRoutes.routes;
}
/**
* Get all UI slot definitions from loaded plugins.
* UI slots are cached and only rebuilt when plugin state changes.
*/
getPluginUiSlots(): Array<{ pluginId: string; slot: PluginUiSlotDefinition }> {
if (!this.cachedUiSlots || this.cachedUiSlots.version !== this.uiSlotsCacheVersion) {
this.cachedUiSlots = {
slots: this.options.pluginLoader.getPluginUiSlots(),
version: this.uiSlotsCacheVersion,
};
}
return this.cachedUiSlots.slots;
}
/**
* Get the underlying plugin loader.
*/
@@ -205,6 +231,7 @@ export class PluginRunner {
await this.options.pluginLoader.reloadPlugin(pluginId);
this.invalidateToolsCache();
this.invalidateRoutesCache();
this.invalidateUiSlotsCache();
executorLog.log(`Plugin ${pluginId} reloaded`);
}
@@ -214,11 +241,14 @@ export class PluginRunner {
* Handle plugin:enabled event - automatically load the plugin.
*/
private async onPluginEnabled(plugin: import("@fusion/core").PluginInstallation): Promise<void> {
// Invalidate caches before the operation to ensure fresh state regardless of outcome
this.invalidateToolsCache();
this.invalidateRoutesCache();
this.invalidateUiSlotsCache();
try {
executorLog.log(`Auto-loading enabled plugin: ${plugin.id}`);
await this.options.pluginLoader.loadPlugin(plugin.id);
this.invalidateToolsCache();
this.invalidateRoutesCache();
} catch (err) {
this.log.error(`Failed to auto-load plugin ${plugin.id}:`, err);
// Don't rethrow - error isolation
@@ -229,11 +259,14 @@ export class PluginRunner {
* Handle plugin:disabled event - automatically stop the plugin.
*/
private async onPluginDisabled(plugin: import("@fusion/core").PluginInstallation): Promise<void> {
// Invalidate caches before the operation to ensure fresh state regardless of outcome
this.invalidateToolsCache();
this.invalidateRoutesCache();
this.invalidateUiSlotsCache();
try {
executorLog.log(`Auto-stopping disabled plugin: ${plugin.id}`);
await this.options.pluginLoader.stopPlugin(plugin.id);
this.invalidateToolsCache();
this.invalidateRoutesCache();
} catch (err) {
this.log.error(`Failed to auto-stop plugin ${plugin.id}:`, err);
// Don't rethrow - error isolation
@@ -244,11 +277,14 @@ export class PluginRunner {
* Handle plugin:unregistered event - ensure plugin is stopped.
*/
private async onPluginUnregistered(plugin: import("@fusion/core").PluginInstallation): Promise<void> {
// Invalidate caches before the operation to ensure fresh state regardless of outcome
this.invalidateToolsCache();
this.invalidateRoutesCache();
this.invalidateUiSlotsCache();
try {
executorLog.log(`Stopping unregistered plugin: ${plugin.id}`);
await this.options.pluginLoader.stopPlugin(plugin.id);
this.invalidateToolsCache();
this.invalidateRoutesCache();
} catch {
// Ignore - plugin might not be loaded
}
@@ -260,6 +296,7 @@ export class PluginRunner {
private onPluginStateChanged(): void {
this.invalidateToolsCache();
this.invalidateRoutesCache();
this.invalidateUiSlotsCache();
}
/**
@@ -268,6 +305,7 @@ export class PluginRunner {
private onPluginUpdated(): void {
this.invalidateToolsCache();
this.invalidateRoutesCache();
this.invalidateUiSlotsCache();
}
/**
@@ -276,6 +314,7 @@ export class PluginRunner {
private onPluginLoaded(_event: { pluginId: string }): void {
this.invalidateToolsCache();
this.invalidateRoutesCache();
this.invalidateUiSlotsCache();
}
/**
@@ -284,6 +323,7 @@ export class PluginRunner {
private onPluginUnloaded(_event: { pluginId: string }): void {
this.invalidateToolsCache();
this.invalidateRoutesCache();
this.invalidateUiSlotsCache();
}
/**
@@ -292,6 +332,7 @@ export class PluginRunner {
private onPluginReloaded(_event: { pluginId: string }): void {
this.invalidateToolsCache();
this.invalidateRoutesCache();
this.invalidateUiSlotsCache();
}
// ── Tool Conversion ───────────────────────────────────────────────
@@ -451,6 +492,14 @@ export class PluginRunner {
this.log.log(`Routes cache invalidated (version: ${this.routesCacheVersion})`);
}
/**
* Invalidate the UI slots cache, forcing rebuild on next access.
*/
private invalidateUiSlotsCache(): void {
this.uiSlotsCacheVersion++;
this.log.log(`UI slots cache invalidated (version: ${this.uiSlotsCacheVersion})`);
}
// ── Store Event Subscriptions ────────────────────────────────────
/**

View File

@@ -189,6 +189,55 @@ describe("Plugin SDK", () => {
};
expect(ctx.pluginId).toBe("test");
});
it("exports PluginUiSlotDefinition type", () => {
// This is a compile-time test - if types are exported correctly, this will compile
const slot: import("../../core/src/plugin-types.js").PluginUiSlotDefinition = {
slotId: "task-detail-tab",
label: "Task Details",
icon: "FileText",
componentPath: "./components/TaskDetailTab.js",
};
expect(slot.slotId).toBe("task-detail-tab");
expect(slot.label).toBe("Task Details");
expect(slot.icon).toBe("FileText");
expect(slot.componentPath).toBe("./components/TaskDetailTab.js");
});
it("PluginUiSlotDefinition icon is optional", () => {
// This is a compile-time test - if types are exported correctly, this will compile
const slot: import("../../core/src/plugin-types.js").PluginUiSlotDefinition = {
slotId: "header-action",
label: "Header Action",
componentPath: "./components/HeaderAction.js",
};
expect(slot.slotId).toBe("header-action");
expect((slot as any).icon).toBeUndefined();
});
it("PluginUiSlotDefinition can be used in FusionPlugin", () => {
// This verifies that PluginUiSlotDefinition can be used in the FusionPlugin interface
const plugin: FusionPlugin = {
manifest: {
id: "test",
name: "Test",
version: "1.0.0",
},
state: "installed",
hooks: {},
tools: [],
routes: [],
uiSlots: [
{
slotId: "custom-tab",
label: "Custom Tab",
componentPath: "./components/CustomTab.js",
},
],
};
expect(plugin.uiSlots).toHaveLength(1);
expect(plugin.uiSlots![0].slotId).toBe("custom-tab");
});
});
// ── validatePluginManifest ───────────────────────────────────────────

View File

@@ -48,6 +48,7 @@ export type {
PluginToolResult,
PluginRouteDefinition,
PluginRouteMethod,
PluginUiSlotDefinition,
PluginContext,
PluginLogger,
FusionPlugin,