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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 11:11:50 -07:00
parent 8b601810e0
commit 729298fa36
6 changed files with 299 additions and 0 deletions

View File

@@ -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.

View File

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

View File

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

View File

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

View File

@@ -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<void> {
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<string, unknown>;
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)

View File

@@ -43,6 +43,7 @@ export interface PluginUpdateInput {
homepage?: string;
path?: string;
dependencies?: string[];
settingsSchema?: Record<string, PluginSettingSchema> | null;
aiScanOnLoad?: boolean;
lastSecurityScan?: PluginSecurityScanResult;
}
@@ -590,6 +591,15 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
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);