feat(FN-2255): add plugin runtime discovery contracts
- Extend core/plugin-sdk types with runtime manifest metadata, runtime factory, and runtime registration exports - Add runtime validation in plugin manifest parsing, including runtimeId slug and semver checks - Add PluginLoader.getPluginRuntimes() and PluginRunner runtime cache/invalidation plumbing across plugin lifecycle events - Expand plugin loader/runner test coverage for runtime discovery and cache behavior, and document runtime registration in PLUGIN_AUTHORING.md
This commit is contained in:
@@ -37,6 +37,7 @@ describe("PluginRunner", () => {
|
||||
invokeHook: ReturnType<typeof vi.fn>;
|
||||
getPluginTools: ReturnType<typeof vi.fn>;
|
||||
getPluginRoutes: ReturnType<typeof vi.fn>;
|
||||
getPluginRuntimes: ReturnType<typeof vi.fn>;
|
||||
getLoadedPlugins: ReturnType<typeof vi.fn>;
|
||||
getPlugin: ReturnType<typeof vi.fn>;
|
||||
loadPlugin: ReturnType<typeof vi.fn>;
|
||||
@@ -76,6 +77,7 @@ describe("PluginRunner", () => {
|
||||
invokeHook: vi.fn().mockResolvedValue(undefined),
|
||||
getPluginTools: vi.fn().mockReturnValue([]),
|
||||
getPluginRoutes: vi.fn().mockReturnValue([]),
|
||||
getPluginRuntimes: vi.fn().mockReturnValue([]),
|
||||
getLoadedPlugins: vi.fn().mockReturnValue([]),
|
||||
getPlugin: vi.fn(),
|
||||
loadPlugin: vi.fn().mockResolvedValue({}),
|
||||
@@ -375,6 +377,99 @@ describe("PluginRunner", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPluginRuntimes()", () => {
|
||||
it("should return runtimes from the loader", async () => {
|
||||
const runtimes = [
|
||||
{
|
||||
pluginId: "test-plugin",
|
||||
runtime: {
|
||||
metadata: {
|
||||
runtimeId: "code-interpreter",
|
||||
name: "Code Interpreter",
|
||||
description: "Executes code in a sandbox",
|
||||
},
|
||||
factory: async () => ({}),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockPluginLoader.getPluginRuntimes.mockReturnValue(runtimes);
|
||||
|
||||
await pluginRunner.init();
|
||||
const result = pluginRunner.getPluginRuntimes();
|
||||
|
||||
expect(result).toEqual(runtimes);
|
||||
expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should return empty array when no runtimes", async () => {
|
||||
await pluginRunner.init();
|
||||
const result = pluginRunner.getPluginRuntimes();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should cache runtimes and rebuild on cache invalidation", async () => {
|
||||
const runtimes = [
|
||||
{
|
||||
pluginId: "test-plugin",
|
||||
runtime: {
|
||||
metadata: {
|
||||
runtimeId: "runtime-v1",
|
||||
name: "Runtime V1",
|
||||
},
|
||||
factory: async () => ({}),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockPluginLoader.getPluginRuntimes.mockReturnValue(runtimes);
|
||||
|
||||
await pluginRunner.init();
|
||||
const runtimes1 = pluginRunner.getPluginRuntimes();
|
||||
|
||||
// Same call should return cached result
|
||||
const runtimes2 = pluginRunner.getPluginRuntimes();
|
||||
expect(runtimes1).toBe(runtimes2);
|
||||
|
||||
// Simulate plugin event that invalidates cache
|
||||
const stateChangeHandler = mockPluginStore.on.mock.calls.find(
|
||||
(call) => call[0] === "plugin:stateChanged",
|
||||
)?.[1];
|
||||
stateChangeHandler?.();
|
||||
|
||||
// Next call should rebuild cache
|
||||
const newRuntimes = [
|
||||
{
|
||||
pluginId: "test-plugin",
|
||||
runtime: {
|
||||
metadata: {
|
||||
runtimeId: "runtime-v2",
|
||||
name: "Runtime V2",
|
||||
},
|
||||
factory: async () => ({}),
|
||||
},
|
||||
},
|
||||
];
|
||||
mockPluginLoader.getPluginRuntimes.mockReturnValue(newRuntimes);
|
||||
|
||||
const runtimes3 = pluginRunner.getPluginRuntimes();
|
||||
expect(runtimes3).toEqual(newRuntimes);
|
||||
expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should invalidate cache on reloadPlugin()", async () => {
|
||||
mockPluginLoader.getPluginRuntimes.mockReturnValue([]);
|
||||
|
||||
await pluginRunner.init();
|
||||
pluginRunner.getPluginRuntimes();
|
||||
|
||||
await pluginRunner.reloadPlugin("test-plugin");
|
||||
|
||||
pluginRunner.getPluginRuntimes();
|
||||
expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLoader() / getStore()", () => {
|
||||
it("should return the plugin loader", () => {
|
||||
expect(pluginRunner.getLoader()).toBe(mockPluginLoader);
|
||||
|
||||
@@ -33,6 +33,7 @@ describe("PluginRunner", () => {
|
||||
getPluginTools: ReturnType<typeof vi.fn>;
|
||||
getPluginRoutes: ReturnType<typeof vi.fn>;
|
||||
getPluginUiSlots: ReturnType<typeof vi.fn>;
|
||||
getPluginRuntimes: ReturnType<typeof vi.fn>;
|
||||
getLoadedPlugins: ReturnType<typeof vi.fn>;
|
||||
getPlugin: ReturnType<typeof vi.fn>;
|
||||
loadPlugin: ReturnType<typeof vi.fn>;
|
||||
@@ -85,6 +86,7 @@ describe("PluginRunner", () => {
|
||||
getPluginTools: vi.fn().mockReturnValue([]),
|
||||
getPluginRoutes: vi.fn().mockReturnValue([]),
|
||||
getPluginUiSlots: vi.fn().mockReturnValue([]),
|
||||
getPluginRuntimes: vi.fn().mockReturnValue([]),
|
||||
getLoadedPlugins: vi.fn().mockReturnValue([]),
|
||||
getPlugin: vi.fn(),
|
||||
loadPlugin: vi.fn().mockResolvedValue({}),
|
||||
@@ -491,6 +493,251 @@ describe("PluginRunner", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPluginRuntimes()", () => {
|
||||
it("should return empty array when no plugins have runtimes", async () => {
|
||||
mockPluginLoader.getPluginRuntimes.mockReturnValue([]);
|
||||
await pluginRunner.init();
|
||||
const runtimes = pluginRunner.getPluginRuntimes();
|
||||
expect(runtimes).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return cached runtimes after plugins load", async () => {
|
||||
const mockRuntimes = [
|
||||
{
|
||||
pluginId: "test-plugin",
|
||||
runtime: {
|
||||
metadata: {
|
||||
runtimeId: "code-interpreter",
|
||||
name: "Code Interpreter",
|
||||
description: "Executes code",
|
||||
},
|
||||
factory: async () => ({}),
|
||||
},
|
||||
},
|
||||
];
|
||||
mockPluginLoader.getPluginRuntimes.mockReturnValue(mockRuntimes);
|
||||
|
||||
await pluginRunner.init();
|
||||
const runtimes1 = pluginRunner.getPluginRuntimes();
|
||||
const runtimes2 = pluginRunner.getPluginRuntimes();
|
||||
|
||||
expect(runtimes1).toEqual(mockRuntimes);
|
||||
expect(runtimes2).toBe(runtimes1); // Same reference (cached)
|
||||
});
|
||||
|
||||
it("should invalidate cache on plugin:reloaded event", async () => {
|
||||
const mockRuntimes = [
|
||||
{
|
||||
pluginId: "test-plugin",
|
||||
runtime: {
|
||||
metadata: {
|
||||
runtimeId: "runtime-v1",
|
||||
name: "Runtime V1",
|
||||
},
|
||||
factory: async () => ({}),
|
||||
},
|
||||
},
|
||||
];
|
||||
mockPluginLoader.getPluginRuntimes.mockReturnValue(mockRuntimes);
|
||||
|
||||
await pluginRunner.init();
|
||||
const runtimes1 = pluginRunner.getPluginRuntimes();
|
||||
expect(runtimes1).toEqual(mockRuntimes);
|
||||
|
||||
// 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 newRuntimes = [
|
||||
{
|
||||
pluginId: "test-plugin",
|
||||
runtime: {
|
||||
metadata: {
|
||||
runtimeId: "runtime-v2",
|
||||
name: "Runtime V2",
|
||||
},
|
||||
factory: async () => ({}),
|
||||
},
|
||||
},
|
||||
];
|
||||
mockPluginLoader.getPluginRuntimes.mockReturnValue(newRuntimes);
|
||||
|
||||
const runtimes2 = pluginRunner.getPluginRuntimes();
|
||||
expect(runtimes2).toEqual(newRuntimes);
|
||||
expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should invalidate cache on plugin:enabled event", async () => {
|
||||
mockPluginLoader.getPluginRuntimes.mockReturnValue([]);
|
||||
await pluginRunner.init();
|
||||
|
||||
// Get initial runtimes
|
||||
pluginRunner.getPluginRuntimes();
|
||||
|
||||
// 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.getPluginRuntimes();
|
||||
expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should invalidate cache on plugin:disabled event", async () => {
|
||||
mockPluginLoader.getPluginRuntimes.mockReturnValue([]);
|
||||
await pluginRunner.init();
|
||||
|
||||
// Get initial runtimes
|
||||
pluginRunner.getPluginRuntimes();
|
||||
|
||||
// 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.getPluginRuntimes();
|
||||
expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should invalidate cache on plugin:stateChanged event", async () => {
|
||||
mockPluginLoader.getPluginRuntimes.mockReturnValue([]);
|
||||
await pluginRunner.init();
|
||||
|
||||
// Get initial runtimes
|
||||
pluginRunner.getPluginRuntimes();
|
||||
|
||||
// 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.getPluginRuntimes();
|
||||
expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should invalidate cache on plugin:updated event", async () => {
|
||||
mockPluginLoader.getPluginRuntimes.mockReturnValue([]);
|
||||
await pluginRunner.init();
|
||||
|
||||
// Get initial runtimes
|
||||
pluginRunner.getPluginRuntimes();
|
||||
|
||||
// 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.getPluginRuntimes();
|
||||
expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should invalidate cache on reloadPlugin()", async () => {
|
||||
mockPluginLoader.getPluginRuntimes.mockReturnValue([]);
|
||||
await pluginRunner.init();
|
||||
|
||||
// Get initial runtimes
|
||||
pluginRunner.getPluginRuntimes();
|
||||
|
||||
// Call reloadPlugin
|
||||
await pluginRunner.reloadPlugin("test-plugin");
|
||||
|
||||
// Next call should rebuild cache
|
||||
pluginRunner.getPluginRuntimes();
|
||||
expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should invalidate cache on plugin:loaded event", async () => {
|
||||
mockPluginLoader.getPluginRuntimes.mockReturnValue([]);
|
||||
await pluginRunner.init();
|
||||
|
||||
// Get initial runtimes
|
||||
pluginRunner.getPluginRuntimes();
|
||||
|
||||
// Simulate plugin:loaded event
|
||||
const loadedHandler = mockPluginLoader.on.mock.calls.find(
|
||||
call => call[0] === "plugin:loaded"
|
||||
)?.[1];
|
||||
|
||||
if (loadedHandler) {
|
||||
loadedHandler({ pluginId: "test-plugin" });
|
||||
}
|
||||
|
||||
// Next call should rebuild cache
|
||||
pluginRunner.getPluginRuntimes();
|
||||
expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should invalidate cache on plugin:unloaded event", async () => {
|
||||
mockPluginLoader.getPluginRuntimes.mockReturnValue([]);
|
||||
await pluginRunner.init();
|
||||
|
||||
// Get initial runtimes
|
||||
pluginRunner.getPluginRuntimes();
|
||||
|
||||
// Simulate plugin:unloaded event
|
||||
const unloadedHandler = mockPluginLoader.on.mock.calls.find(
|
||||
call => call[0] === "plugin:unloaded"
|
||||
)?.[1];
|
||||
|
||||
if (unloadedHandler) {
|
||||
unloadedHandler({ pluginId: "test-plugin" });
|
||||
}
|
||||
|
||||
// Next call should rebuild cache
|
||||
pluginRunner.getPluginRuntimes();
|
||||
expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLoader() / getStore()", () => {
|
||||
it("should return the plugin loader", () => {
|
||||
const loader = pluginRunner.getLoader();
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
PluginToolDefinition,
|
||||
PluginRouteDefinition,
|
||||
PluginUiSlotDefinition,
|
||||
PluginRuntimeRegistration,
|
||||
PluginContext,
|
||||
} from "@fusion/core";
|
||||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
@@ -60,6 +61,14 @@ interface CachedUiSlots {
|
||||
version: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached runtimes - rebuilt when plugin state changes
|
||||
*/
|
||||
interface CachedRuntimes {
|
||||
runtimes: Array<{ pluginId: string; runtime: PluginRuntimeRegistration }>;
|
||||
version: number;
|
||||
}
|
||||
|
||||
const DEFAULT_HOOK_TIMEOUT_MS = 5000;
|
||||
|
||||
export class PluginRunner {
|
||||
@@ -67,9 +76,11 @@ export class PluginRunner {
|
||||
private cachedTools: CachedTools | null = null;
|
||||
private cachedRoutes: CachedRoutes | null = null;
|
||||
private cachedUiSlots: CachedUiSlots | null = null;
|
||||
private cachedRuntimes: CachedRuntimes | null = null;
|
||||
private toolsCacheVersion = 0;
|
||||
private routesCacheVersion = 0;
|
||||
private uiSlotsCacheVersion = 0;
|
||||
private runtimesCacheVersion = 0;
|
||||
private hookTimeoutMs: number;
|
||||
|
||||
// Event handler references for cleanup
|
||||
@@ -126,6 +137,7 @@ export class PluginRunner {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
this.invalidateRuntimesCache();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,6 +220,20 @@ export class PluginRunner {
|
||||
return this.cachedUiSlots.slots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all runtime registrations from loaded plugins.
|
||||
* Runtimes are cached and only rebuilt when plugin state changes.
|
||||
*/
|
||||
getPluginRuntimes(): Array<{ pluginId: string; runtime: PluginRuntimeRegistration }> {
|
||||
if (!this.cachedRuntimes || this.cachedRuntimes.version !== this.runtimesCacheVersion) {
|
||||
this.cachedRuntimes = {
|
||||
runtimes: this.options.pluginLoader.getPluginRuntimes(),
|
||||
version: this.runtimesCacheVersion,
|
||||
};
|
||||
}
|
||||
return this.cachedRuntimes.runtimes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying plugin loader.
|
||||
*/
|
||||
@@ -224,7 +250,7 @@ export class PluginRunner {
|
||||
|
||||
/**
|
||||
* Reload a plugin: stop the old instance, re-import, and start the new one.
|
||||
* This invalidates the tools and routes caches.
|
||||
* This invalidates the tools, routes, uiSlots, and runtimes caches.
|
||||
*/
|
||||
async reloadPlugin(pluginId: string): Promise<void> {
|
||||
executorLog.log(`Reloading plugin: ${pluginId}`);
|
||||
@@ -232,6 +258,7 @@ export class PluginRunner {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
this.invalidateRuntimesCache();
|
||||
executorLog.log(`Plugin ${pluginId} reloaded`);
|
||||
}
|
||||
|
||||
@@ -245,6 +272,7 @@ export class PluginRunner {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
this.invalidateRuntimesCache();
|
||||
|
||||
try {
|
||||
executorLog.log(`Auto-loading enabled plugin: ${plugin.id}`);
|
||||
@@ -263,6 +291,7 @@ export class PluginRunner {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
this.invalidateRuntimesCache();
|
||||
|
||||
try {
|
||||
executorLog.log(`Auto-stopping disabled plugin: ${plugin.id}`);
|
||||
@@ -281,6 +310,7 @@ export class PluginRunner {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
this.invalidateRuntimesCache();
|
||||
|
||||
try {
|
||||
executorLog.log(`Stopping unregistered plugin: ${plugin.id}`);
|
||||
@@ -298,6 +328,7 @@ export class PluginRunner {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
this.invalidateRuntimesCache();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -307,6 +338,7 @@ export class PluginRunner {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
this.invalidateRuntimesCache();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -316,6 +348,7 @@ export class PluginRunner {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
this.invalidateRuntimesCache();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -325,6 +358,7 @@ export class PluginRunner {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
this.invalidateRuntimesCache();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -334,6 +368,7 @@ export class PluginRunner {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
this.invalidateRuntimesCache();
|
||||
}
|
||||
|
||||
// ── Tool Conversion ───────────────────────────────────────────────
|
||||
@@ -502,6 +537,14 @@ export class PluginRunner {
|
||||
this.log.log(`UI slots cache invalidated (version: ${this.uiSlotsCacheVersion})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the runtimes cache, forcing rebuild on next access.
|
||||
*/
|
||||
private invalidateRuntimesCache(): void {
|
||||
this.runtimesCacheVersion++;
|
||||
this.log.log(`Runtimes cache invalidated (version: ${this.runtimesCacheVersion})`);
|
||||
}
|
||||
|
||||
// ── Store Event Subscriptions ────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user