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

@@ -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 ────────────────────────────────────
/**