From 729298fa368d4a8782823c6c16f37d1eef278682 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 12 Jul 2026 11:11:50 -0700 Subject: [PATCH] FN-7855: refresh persisted plugin manifest metadata on reload/re-import Path-registered plugin reload/restart now refreshes persisted manifest metadata instead of leaving stale version/settingsSchema in the store. - PluginLoader.loadPlugin/reloadPlugin call a new refreshPersistedManifestMetadata helper after each fresh module import, generalizing the previously bundled-only refresh to path-registered plugins - Refresh is metadata-only (version, settingsSchema) via a stable-JSON comparison, preserving per-project enablement and saved setting values, and is a no-op when nothing changed - PluginStore.PluginUpdateInput/updatePlugin gain a settingsSchema field (undefined = unchanged, null = explicitly clear) so updatePlugin can persist manifest schema changes independently of setting values - Docs: add a "Updating path-registered plugins" section to docs/PLUGIN_AUTHORING.md describing the new reload/refresh loop - Tests: add coverage in plugin-loader.test.ts and plugin-store.test.ts for manifest metadata refresh on load/reload and settingsSchema persistence - Add a patch changeset for @runfusion/fusion Files changed: .changeset/fn-7855-plugin-manifest-refresh.md | 7 + docs/PLUGIN_AUTHORING.md | 10 ++ packages/core/src/__tests__/plugin-loader.test.ts | 177 ++++++++++++++++++++++ packages/core/src/__tests__/plugin-store.test.ts | 42 +++++ packages/core/src/plugin-loader.ts | 53 +++++++ packages/core/src/plugin-store.ts | 10 ++ 6 files changed, 299 insertions(+) Fusion-Task-Id: FN-7855 Fusion-Task-Lineage: f4d94023-5a27-4059-a7a5-61f524c171b8 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7855-plugin-manifest-refresh.md | 7 + docs/PLUGIN_AUTHORING.md | 10 + .../core/src/__tests__/plugin-loader.test.ts | 177 ++++++++++++++++++ .../core/src/__tests__/plugin-store.test.ts | 42 +++++ packages/core/src/plugin-loader.ts | 53 ++++++ packages/core/src/plugin-store.ts | 10 + 6 files changed, 299 insertions(+) create mode 100644 .changeset/fn-7855-plugin-manifest-refresh.md diff --git a/.changeset/fn-7855-plugin-manifest-refresh.md b/.changeset/fn-7855-plugin-manifest-refresh.md new file mode 100644 index 0000000000..330a639863 --- /dev/null +++ b/.changeset/fn-7855-plugin-manifest-refresh.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Reloading a path-registered plugin now refreshes its version and settings schema. +category: fix +dev: PluginLoader.loadPlugin/reloadPlugin reconcile persisted version/settingsSchema from the freshly-imported manifest (generalizing the bundled-plugin refresh); PluginUpdateInput/updatePlugin now accept settingsSchema. Preserves per-project enablement and setting values. FN-7855. diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 175868d915..72f24127a3 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -1099,6 +1099,16 @@ Any state can transition to: | `stopped` | Plugin shut down gracefully | | `error` | Plugin failed during load or execution | +### Updating path-registered plugins + +For plugins installed from a filesystem path, the normal update loop is now: + +1. Pull or edit the plugin source. +2. Rebuild the plugin entrypoint if your plugin uses a build step. +3. Restart Fusion, or disable and re-enable the plugin. + +On the next load/reload, Fusion re-imports the plugin module and refreshes the persisted manifest `version` and `settingsSchema` from the rebuilt manifest. Existing per-project enablement and saved setting values are preserved, so you do not need to unregister and re-register a path-based plugin just to expose a new version or settings field. + --- ## 12. Testing Plugins diff --git a/packages/core/src/__tests__/plugin-loader.test.ts b/packages/core/src/__tests__/plugin-loader.test.ts index 6dd027f339..a17726eaec 100644 --- a/packages/core/src/__tests__/plugin-loader.test.ts +++ b/packages/core/src/__tests__/plugin-loader.test.ts @@ -451,6 +451,111 @@ describe("PluginLoader", () => { expect(loader.isPluginLoaded("load-test")).toBe(true); }); + it.each([ + { + name: "version-only change", + stored: makeManifest({ id: "manifest-load-version", version: "1.0.0", settingsSchema: { s1: { type: "string" } } }), + imported: makeManifest({ id: "manifest-load-version", version: "1.1.0", settingsSchema: { s1: { type: "string" } } }), + expectedVersion: "1.1.0", + expectedSchema: { s1: { type: "string" } }, + expectUpdatedEvent: true, + }, + { + name: "settingsSchema added key", + stored: makeManifest({ id: "manifest-load-schema-add", settingsSchema: { s1: { type: "string" } } }), + imported: makeManifest({ id: "manifest-load-schema-add", settingsSchema: { s1: { type: "string" }, s2: { type: "number" } } }), + expectedVersion: "1.0.0", + expectedSchema: { s1: { type: "string" }, s2: { type: "number" } }, + expectUpdatedEvent: true, + }, + { + name: "settingsSchema enum changed", + stored: makeManifest({ id: "manifest-load-schema-enum", settingsSchema: { mode: { type: "enum", enumValues: ["fast", "safe"] } } }), + imported: makeManifest({ id: "manifest-load-schema-enum", settingsSchema: { mode: { type: "enum", enumValues: ["fast", "safe", "turbo"] } } }), + expectedVersion: "1.0.0", + expectedSchema: { mode: { type: "enum", enumValues: ["fast", "safe", "turbo"] } }, + expectUpdatedEvent: true, + }, + { + name: "settingsSchema removed", + stored: makeManifest({ id: "manifest-load-schema-remove", settingsSchema: { s1: { type: "string" } } }), + imported: makeManifest({ id: "manifest-load-schema-remove", settingsSchema: undefined }), + expectedVersion: "1.0.0", + expectedSchema: undefined, + expectUpdatedEvent: true, + }, + { + name: "first-time settingsSchema addition", + stored: makeManifest({ id: "manifest-load-schema-first", settingsSchema: undefined }), + imported: makeManifest({ id: "manifest-load-schema-first", settingsSchema: { s2: { type: "boolean" } } }), + expectedVersion: "1.0.0", + expectedSchema: { s2: { type: "boolean" } }, + expectUpdatedEvent: true, + }, + { + name: "version and settingsSchema changed", + stored: makeManifest({ id: "manifest-load-both", version: "1.0.0", settingsSchema: { s1: { type: "string" } } }), + imported: makeManifest({ id: "manifest-load-both", version: "1.1.0", settingsSchema: { s1: { type: "string" }, s2: { type: "number" } } }), + expectedVersion: "1.1.0", + expectedSchema: { s1: { type: "string" }, s2: { type: "number" } }, + expectUpdatedEvent: true, + }, + { + name: "unchanged manifest", + stored: makeManifest({ id: "manifest-load-unchanged", version: "1.0.0", settingsSchema: { s1: { type: "string" } } }), + imported: makeManifest({ id: "manifest-load-unchanged", version: "1.0.0", settingsSchema: { s1: { type: "string" } } }), + expectedVersion: "1.0.0", + expectedSchema: { s1: { type: "string" } }, + expectUpdatedEvent: false, + }, + ])("refreshes persisted manifest metadata on loadPlugin for $name", async ({ + stored, + imported, + expectedVersion, + expectedSchema, + expectUpdatedEvent, + }) => { + await pluginStore.init(); + + const pluginDir = join(rootDir, "plugins"); + const pluginPath = await writePluginModule(pluginDir, `${stored.id}.js`, makePlugin(imported)); + await pluginStore.registerPlugin({ manifest: stored, path: pluginPath, settings: { s1: "saved-value" } }); + const updatePluginSpy = vi.spyOn(pluginStore, "updatePlugin"); + + const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore }); + await loader.loadPlugin(stored.id); + + const persisted = await pluginStore.getPlugin(stored.id); + expect(persisted.version).toBe(expectedVersion); + expect(persisted.settingsSchema).toEqual(expectedSchema); + expect(persisted.enabled).toBe(true); + expect(persisted.settings).toEqual({ s1: "saved-value" }); + expect(updatePluginSpy).toHaveBeenCalledTimes(expectUpdatedEvent ? 1 : 0); + }); + + it("keeps bundled-plugin load idempotent when persisted metadata already matches", async () => { + await pluginStore.init(); + + const pluginDir = join(rootDir, "plugins"); + const manifest = makeManifest({ + id: "fusion-plugin-bundled-idempotent", + version: "2.0.0", + settingsSchema: { enabled: { type: "boolean" } }, + }); + const pluginPath = await writePluginModule(pluginDir, "bundled-idempotent.js", makePlugin(manifest)); + await pluginStore.registerPlugin({ manifest, path: pluginPath }); + const updatePluginSpy = vi.spyOn(pluginStore, "updatePlugin"); + + const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore }); + await loader.loadPlugin(manifest.id); + + expect(updatePluginSpy).not.toHaveBeenCalled(); + await expect(pluginStore.getPlugin(manifest.id)).resolves.toMatchObject({ + version: "2.0.0", + settingsSchema: { enabled: { type: "boolean" } }, + }); + }); + it("records activation analytics only for a genuine successful plugin load", async () => { await pluginStore.init(); @@ -1395,6 +1500,78 @@ export default plugin; }); }); + it("refreshes persisted version and settingsSchema on reloadPlugin while preserving settings", async () => { + await pluginStore.init(); + + const pluginId = "manifest-reload-refresh"; + const pluginDir = join(rootDir, "plugins"); + const pluginPath = await writePluginModule( + pluginDir, + "manifest-reload-refresh.js", + makePlugin(makeManifest({ id: pluginId, version: "1.0.0", settingsSchema: { s1: { type: "string" } } })), + ); + await pluginStore.registerPlugin({ + manifest: makeManifest({ id: pluginId, version: "1.0.0", settingsSchema: { s1: { type: "string" } } }), + path: pluginPath, + settings: { s1: "saved-value" }, + }); + + const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore }); + await loader.loadPlugin(pluginId); + await writePluginModule( + pluginDir, + "manifest-reload-refresh.js", + makePlugin(makeManifest({ + id: pluginId, + version: "1.1.0", + settingsSchema: { s1: { type: "string" }, s2: { type: "number" } }, + })), + ); + const now = new Date(); + utimesSync(pluginPath, now, now); + + await loader.reloadPlugin(pluginId); + + const persisted = await pluginStore.getPlugin(pluginId); + expect(persisted.version).toBe("1.1.0"); + expect(persisted.settingsSchema).toEqual({ s1: { type: "string" }, s2: { type: "number" } }); + expect(persisted.enabled).toBe(true); + expect(persisted.settings).toEqual({ s1: "saved-value" }); + }); + + it("does not persist new manifest metadata when reload rolls back", async () => { + await pluginStore.init(); + + const pluginId = "manifest-reload-rollback"; + const pluginDir = join(rootDir, "plugins"); + const pluginPath = await writePluginModule( + pluginDir, + "manifest-reload-rollback.js", + makePlugin(makeManifest({ id: pluginId, version: "1.0.0", settingsSchema: { s1: { type: "string" } } })), + ); + await pluginStore.registerPlugin({ + manifest: makeManifest({ id: pluginId, version: "1.0.0", settingsSchema: { s1: { type: "string" } } }), + path: pluginPath, + }); + + const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore }); + await loader.loadPlugin(pluginId); + await writePluginWithHooks( + pluginDir, + "manifest-reload-rollback.js", + { onLoad: "(async () => { throw new Error('new onLoad failed'); })" }, + makeManifest({ id: pluginId, version: "1.1.0", settingsSchema: { s1: { type: "string" }, s2: { type: "number" } } }), + ); + const now = new Date(); + utimesSync(pluginPath, now, now); + + await expect(loader.reloadPlugin(pluginId)).rejects.toThrow("new onLoad failed"); + + const persisted = await pluginStore.getPlugin(pluginId); + expect(persisted.version).toBe("1.0.0"); + expect(persisted.settingsSchema).toEqual({ s1: { type: "string" } }); + }); + it("logs reload failures", async () => { await pluginStore.init(); diff --git a/packages/core/src/__tests__/plugin-store.test.ts b/packages/core/src/__tests__/plugin-store.test.ts index ec9ba82582..ed9979ff44 100644 --- a/packages/core/src/__tests__/plugin-store.test.ts +++ b/packages/core/src/__tests__/plugin-store.test.ts @@ -936,6 +936,48 @@ describe("PluginStore", () => { expect(plugin.path).toBe("/new/path/to/plugin"); }); + it("updates version and settingsSchema without touching enabled or setting values", async () => { + const manifest = makeManifest({ + settingsSchema: { + apiKey: { type: "string", defaultValue: "default-key" }, + }, + }); + await store.registerPlugin({ manifest, path: "/path/to/plugin" }); + await store.updatePluginSettings("test-plugin", { apiKey: "saved-key" }); + await store.disablePlugin("test-plugin"); + + const plugin = await store.updatePlugin("test-plugin", { + version: "1.1.0", + settingsSchema: { + apiKey: { type: "string", defaultValue: "default-key" }, + mode: { type: "enum", enumValues: ["fast", "safe"], defaultValue: "safe" }, + }, + }); + + expect(plugin.version).toBe("1.1.0"); + expect(plugin.settingsSchema?.mode).toEqual({ type: "enum", enumValues: ["fast", "safe"], defaultValue: "safe" }); + expect(plugin.settings).toEqual({ apiKey: "saved-key" }); + expect(plugin.enabled).toBe(false); + + const reloaded = await store.getPlugin("test-plugin"); + expect(reloaded.settingsSchema?.mode?.enumValues).toEqual(["fast", "safe"]); + expect(reloaded.settings).toEqual({ apiKey: "saved-key" }); + expect(reloaded.enabled).toBe(false); + }); + + it("clears settingsSchema when updatePlugin receives null", async () => { + const manifest = makeManifest({ + settingsSchema: { + apiKey: { type: "string" }, + }, + }); + await store.registerPlugin({ manifest, path: "/path/to/plugin" }); + + const plugin = await store.updatePlugin("test-plugin", { settingsSchema: null }); + + expect(plugin.settingsSchema).toBeUndefined(); + }); + it("updates dependencies", async () => { const manifest = makeManifest(); await store.registerPlugin({ manifest, path: "/path/to/plugin" }); diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index b70932eccd..ab2fdc415e 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -30,6 +30,7 @@ import type { PluginRuntimeRegistration, CliProviderContribution, PluginInstallation, + PluginManifest, PluginSkillContribution, PluginWorkflowStepContribution, PluginTraitContribution, @@ -355,6 +356,8 @@ export class PluginLoader extends EventEmitter<{ ); } + await this.refreshPersistedManifestMetadata(installation, plugin.manifest); + // Check version compatibility if (plugin.manifest.fusionVersion) { const compatible = this.checkVersionCompatibility( @@ -422,6 +425,54 @@ export class PluginLoader extends EventEmitter<{ } } + private async refreshPersistedManifestMetadata( + installation: PluginInstallation, + manifest: PluginManifest, + ): Promise { + const versionChanged = installation.version !== manifest.version; + const settingsSchemaChanged = + this.stableManifestMetadataJson(installation.settingsSchema ?? null) !== + this.stableManifestMetadataJson(manifest.settingsSchema ?? null); + + if (!versionChanged && !settingsSchemaChanged) { + return; + } + + try { + /* + FNXC:Plugins 2026-07-12-10:59: + FN-7855 requires each fresh module re-import to reconcile persisted manifest metadata for path-registered plugins, because rebuilt code can change version/settingsSchema without re-registration. + Keep this update metadata-only so per-project enablement and saved setting values survive reload/restart; bundled plugins may already be current from ensureBundledPluginInstalled, making this idempotent. + */ + await this.options.pluginStore.updatePlugin(installation.id, { + ...(versionChanged ? { version: manifest.version } : {}), + ...(settingsSchemaChanged ? { settingsSchema: manifest.settingsSchema ?? null } : {}), + }); + } catch (err) { + this.log.warn( + `Failed to refresh persisted manifest metadata for plugin ${installation.id}:`, + err, + ); + } + } + + private stableManifestMetadataJson(value: unknown): string { + if (value === null || value === undefined) { + return "null"; + } + if (Array.isArray(value)) { + return `[${value.map((entry) => this.stableManifestMetadataJson(entry)).join(",")}]`; + } + if (typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${this.stableManifestMetadataJson(record[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); + } + private resolvePluginPath(path: string): string { // If already absolute, use as-is if (isAbsolute(path)) { @@ -558,6 +609,8 @@ export class PluginLoader extends EventEmitter<{ `onLoad timeout for ${pluginId}`, ); + await this.refreshPersistedManifestMetadata(installation, newPlugin.manifest); + // State is already "started", no need to update store // (avoiding started -> started transition which is disallowed) diff --git a/packages/core/src/plugin-store.ts b/packages/core/src/plugin-store.ts index 62b568cee4..da2794ffe5 100644 --- a/packages/core/src/plugin-store.ts +++ b/packages/core/src/plugin-store.ts @@ -43,6 +43,7 @@ export interface PluginUpdateInput { homepage?: string; path?: string; dependencies?: string[]; + settingsSchema?: Record | null; aiScanOnLoad?: boolean; lastSecurityScan?: PluginSecurityScanResult; } @@ -590,6 +591,15 @@ export class PluginStore extends EventEmitter { setClauses.push("dependencies = ?"); params.push(toJson(updates.dependencies)); } + /* + FNXC:Plugins 2026-07-12-10:59: + FN-7855 requires updatePlugin to persist manifest settingsSchema changes independently from per-project setting values so path-registered plugin reloads can refresh dashboard metadata without unregistering the plugin. + Undefined means "leave schema unchanged"; null explicitly clears the persisted schema when a rebuilt manifest removes it. + */ + if (updates.settingsSchema !== undefined) { + setClauses.push("settingsSchema = ?"); + params.push(updates.settingsSchema === null ? null : toJson(updates.settingsSchema)); + } if (updates.aiScanOnLoad !== undefined) { setClauses.push("aiScanOnLoad = ?"); params.push(updates.aiScanOnLoad ? 1 : 0);