feat(FN-1914): merge fusion/fn-1914
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 ────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user