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:
7
.changeset/fn-7855-plugin-manifest-refresh.md
Normal file
7
.changeset/fn-7855-plugin-manifest-refresh.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user