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:
Fusion
2026-04-22 13:01:25 -07:00
committed by gsxdsm
parent 2873bc0388
commit c187c670e1
10 changed files with 1304 additions and 14 deletions

View File

@@ -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);

View File

@@ -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();

View File

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