FN-7201: refresh plugin dashboard-view metadata from manifests

Refresh plugin dashboard navigation metadata from the current on-disk manifest so rebuilt plugins do not serve stale view details.

- Resolve the authoritative manifest path from plugin entry directories and package roots.
- Re-read and validate dashboardViews metadata before returning dashboard navigation entries, falling back only when the manifest cannot be used.
- Await refreshed dashboard-view metadata in the dashboard route and cover rebuild/removal cases with tests.
- Add a patch changeset for the published CLI package.

Files changed:
 .../FN-7201-plugin-dashboard-view-refresh.md       |   7 ++
 packages/core/src/__tests__/plugin-loader.test.ts  | 114 ++++++++++++++++++++-
 packages/core/src/plugin-loader.ts                 |  94 ++++++++++++++++-
 .../dashboard/src/__tests__/plugin-routes.test.ts  |  42 +++++++-
 packages/dashboard/src/routes.ts                   |   2 +-
 5 files changed, 244 insertions(+), 15 deletions(-)

Fusion-Task-Id: FN-7201

Fusion-Task-Lineage: dbdd5575-1999-4c9c-93a4-25eb8f213e07

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-28 13:23:00 -07:00
parent 6cf6ad3bd2
commit b2e1c3efc2
5 changed files with 244 additions and 15 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Plugin sidebar icons now refresh after a plugin rebuild instead of showing stale glyphs.
category: fix
dev: Dashboard-view metadata is re-derived from the authoritative on-disk manifest so rebuilt plugins do not serve stale dashboardViews icon, label, or placement values to navigation while the in-view bundle is current.

View File

@@ -1969,7 +1969,7 @@ export default plugin;
it("returns empty array when no plugins loaded", async () => {
await pluginStore.init();
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
expect(loader.getPluginDashboardViews()).toEqual([]);
await expect(loader.getPluginDashboardViews()).resolves.toEqual([]);
});
it("returns aggregated views from a single plugin", async () => {
@@ -1985,7 +1985,7 @@ export default plugin;
],
} as FusionPlugin);
const views = loader.getPluginDashboardViews();
const views = await loader.getPluginDashboardViews();
expect(views.map((entry) => entry.pluginId + ":" + entry.view.viewId)).toEqual([
"views-a:graph",
"views-a:timeline",
@@ -2009,7 +2009,7 @@ export default plugin;
dashboardViews: [{ viewId: "timeline", label: "Timeline", componentPath: "./timeline.js" }],
} as FusionPlugin);
const views = loader.getPluginDashboardViews();
const views = await loader.getPluginDashboardViews();
expect(views).toHaveLength(2);
expect(views.map((entry) => entry.pluginId + ":" + entry.view.viewId)).toEqual([
"views-a:graph",
@@ -2040,7 +2040,7 @@ export default plugin;
],
} as FusionPlugin);
expect(loader.getPluginDashboardViews()).toEqual([
await expect(loader.getPluginDashboardViews()).resolves.toEqual([
{
pluginId: "views-shape",
view: {
@@ -2055,6 +2055,112 @@ export default plugin;
},
]);
});
it("serves current on-disk manifest dashboard-view metadata when the loaded module is stale", async () => {
await pluginStore.init();
const pluginDir = join(rootDir, "generic-nav-plugin");
await mkdir(pluginDir, { recursive: true });
const entryPath = join(pluginDir, "bundled.js");
await writeFile(entryPath, "export default {};\n");
const currentDashboardViews = [
{
viewId: "overview",
label: "Current Overview",
componentPath: "./dashboard/overview.js",
icon: "Boxes",
placement: "primary" as const,
order: 10,
},
{
viewId: "details",
label: "Current Details",
componentPath: "./dashboard/details.js",
icon: "Network",
placement: "more" as const,
description: "Fresh manifest metadata",
},
];
const currentManifest = {
...makeManifest({ id: "generic-nav-plugin", name: "Generic Nav Plugin" }),
dashboardViews: currentDashboardViews,
};
await writeFile(join(pluginDir, "manifest.json"), JSON.stringify(currentManifest));
await pluginStore.registerPlugin({ manifest: currentManifest as PluginManifest, path: entryPath });
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
(loader as any).plugins.set("generic-nav-plugin", {
manifest: makeManifest({ id: "generic-nav-plugin", name: "Generic Nav Plugin" }),
state: "started",
hooks: {},
dashboardViews: [
{
viewId: "overview",
label: "Stale Overview",
componentPath: "./dashboard/overview.js",
icon: "Sparkles",
placement: "overflow",
},
],
} as FusionPlugin);
await expect(loader.getPluginDashboardViews()).resolves.toEqual([
{ pluginId: "generic-nav-plugin", view: currentDashboardViews[0] },
{ pluginId: "generic-nav-plugin", view: currentDashboardViews[1] },
]);
});
it("treats an empty on-disk dashboardViews array as current metadata", async () => {
await pluginStore.init();
const pluginDir = join(rootDir, "generic-empty-plugin");
await mkdir(pluginDir, { recursive: true });
const entryPath = join(pluginDir, "dist", "index.js");
await mkdir(join(pluginDir, "dist"), { recursive: true });
await writeFile(entryPath, "export default {};\n");
const currentManifest = {
...makeManifest({ id: "generic-empty-plugin", name: "Generic Empty Plugin" }),
dashboardViews: [],
};
await writeFile(join(pluginDir, "manifest.json"), JSON.stringify(currentManifest));
await pluginStore.registerPlugin({ manifest: currentManifest as PluginManifest, path: entryPath });
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
(loader as any).plugins.set("generic-empty-plugin", {
manifest: makeManifest({ id: "generic-empty-plugin", name: "Generic Empty Plugin" }),
state: "started",
hooks: {},
dashboardViews: [{ viewId: "old", label: "Old", componentPath: "./old.js", icon: "Sparkles" }],
} as FusionPlugin);
await expect(loader.getPluginDashboardViews()).resolves.toEqual([]);
});
it("treats a valid on-disk manifest without dashboardViews as no current nav entries", async () => {
await pluginStore.init();
const pluginDir = join(rootDir, "generic-removed-views-plugin");
await mkdir(pluginDir, { recursive: true });
const entryPath = join(pluginDir, "dist", "index.js");
await mkdir(join(pluginDir, "dist"), { recursive: true });
await writeFile(entryPath, "export default {};\n");
const currentManifest = makeManifest({
id: "generic-removed-views-plugin",
name: "Generic Removed Views Plugin",
});
await writeFile(join(pluginDir, "manifest.json"), JSON.stringify(currentManifest));
await pluginStore.registerPlugin({ manifest: currentManifest as PluginManifest, path: entryPath });
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
(loader as any).plugins.set("generic-removed-views-plugin", {
manifest: {
...makeManifest({ id: "generic-removed-views-plugin", name: "Generic Removed Views Plugin" }),
dashboardViews: [{ viewId: "old", label: "Old", componentPath: "./old.js", icon: "Sparkles" }],
},
state: "started",
hooks: {},
dashboardViews: [{ viewId: "old", label: "Old", componentPath: "./old.js", icon: "Sparkles" }],
} as FusionPlugin);
await expect(loader.getPluginDashboardViews()).resolves.toEqual([]);
});
});
describe("getPluginSchemaInitHooks", () => {

View File

@@ -11,8 +11,7 @@
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
import { existsSync, readdirSync, statSync } from "node:fs";
import { stat } from "node:fs/promises";
import { copyFile } from "node:fs/promises";
import { copyFile, readFile, stat } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import { EventEmitter } from "node:events";
import type { TaskStore } from "./store.js";
@@ -49,6 +48,10 @@ import { scanPluginSecurity } from "./plugin-security-scan.js";
// Minimum Fusion version for plugin compatibility checks (can be expanded later)
const MINIMUM_FUSION_VERSION = "0.1.0";
let moduleImportVersion = 0;
const PLUGIN_MANIFEST_PARENT_DIR_NAMES = new Set(["dist", "build", "lib", "src"]);
type CurrentManifestDashboardViewsResult =
| { found: true; dashboardViews: PluginDashboardViewDefinition[] }
| { found: false };
/**
* Resolve the actual loadable entry FILE path for a plugin directory. Node ESM
@@ -1074,14 +1077,95 @@ export class PluginLoader extends EventEmitter<{
});
}
private async resolveCurrentManifestPath(pluginEntryPath: string): Promise<string | null> {
const candidates = new Set<string>();
try {
const entryStats = await stat(pluginEntryPath);
if (entryStats.isDirectory()) {
candidates.add(join(pluginEntryPath, "manifest.json"));
}
} catch {
// The entry file can be temporarily absent during rebuilds; still try the
// package-root candidates derived from the persisted loadable path.
}
const entryDir = dirname(pluginEntryPath);
candidates.add(join(entryDir, "manifest.json"));
if (PLUGIN_MANIFEST_PARENT_DIR_NAMES.has(basename(entryDir))) {
candidates.add(join(dirname(entryDir), "manifest.json"));
}
for (const candidate of candidates) {
try {
const candidateStats = await stat(candidate);
if (candidateStats.isFile()) {
return candidate;
}
} catch {
// Try the next candidate so unusual installs keep falling back safely.
}
}
return null;
}
private async getCurrentManifestDashboardViews(pluginId: string): Promise<CurrentManifestDashboardViewsResult> {
let installation: PluginInstallation;
try {
installation = await this.options.pluginStore.getPlugin(pluginId);
} catch (err) {
this.log.warn(`Could not refresh dashboard views for ${pluginId}:`, err);
return { found: false };
}
const pluginPath = this.resolvePluginPath(installation.path);
const manifestPath = await this.resolveCurrentManifestPath(pluginPath);
if (!manifestPath) {
return { found: false };
}
let manifest: unknown;
try {
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
} catch (err) {
this.log.warn(`Could not read dashboard-view manifest metadata for ${pluginId}:`, err);
return { found: false };
}
const validation = validatePluginManifest(manifest);
if (!validation.valid) {
this.log.warn(`Could not refresh dashboard views for ${pluginId}: ${validation.errors.join(", ")}`);
return { found: false };
}
const dashboardViews = (manifest as { dashboardViews?: unknown }).dashboardViews;
if (dashboardViews === undefined) {
return { found: true, dashboardViews: [] };
}
return { found: true, dashboardViews: dashboardViews as PluginDashboardViewDefinition[] };
}
/**
* Get all top-level dashboard view definitions from loaded plugins.
*
* FNXC:Plugins 2026-06-28-12:30:
* Navigation metadata must come from the current on-disk manifest when present because dashboard component bundles can update immediately after a rebuild while the loaded plugin module instance remains cached. Reading manifest dashboardViews here keeps desktop and mobile nav icon/label/placement in sync with the served in-view bundle for every plugin, without per-plugin pins.
*
* FNXC:Plugins 2026-06-28-19:58:
* A valid manifest that omits dashboardViews is authoritative and means the rebuilt plugin now exposes no top-level views. Do not fall back to stale module dashboardViews after a successful manifest read; fallback is only for missing/unreadable/invalid manifests.
*/
getPluginDashboardViews(): Array<{ pluginId: string; view: PluginDashboardViewDefinition }> {
async getPluginDashboardViews(): Promise<Array<{ pluginId: string; view: PluginDashboardViewDefinition }>> {
const views: Array<{ pluginId: string; view: PluginDashboardViewDefinition }> = [];
for (const [pluginId, plugin] of this.plugins) {
if (plugin.dashboardViews) {
for (const view of plugin.dashboardViews) {
const currentManifestDashboardViews = await this.getCurrentManifestDashboardViews(pluginId);
const dashboardViews = currentManifestDashboardViews.found
? currentManifestDashboardViews.dashboardViews
: plugin.dashboardViews;
if (dashboardViews) {
for (const view of dashboardViews) {
views.push({ pluginId, view });
}
}

View File

@@ -100,7 +100,7 @@ function createMockPluginLoader(overrides: Partial<PluginLoader> = {}): PluginLo
getPluginUiSlots: vi.fn().mockReturnValue([]),
getPluginUiContributions: vi.fn().mockReturnValue([]),
getPluginRuntimes: vi.fn().mockReturnValue([]),
getPluginDashboardViews: vi.fn().mockReturnValue([]),
getPluginDashboardViews: vi.fn().mockResolvedValue([]),
createRouteContext: vi.fn(async (pluginId: string, overrides?: { taskStore?: TaskStore; settings?: Record<string, unknown>; resolveProjectTaskStore?: (projectId: string) => Promise<TaskStore> }) => ({
pluginId,
taskStore: overrides?.taskStore ?? createMockTaskStore(),
@@ -1243,7 +1243,7 @@ describe("GET /api/plugins/dashboard-views", () => {
});
it("returns 200 with empty array when no plugins have dashboard views", async () => {
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockReturnValue([]);
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const res = await performGet(buildApp(), "/api/plugins/dashboard-views");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
@@ -1264,7 +1264,7 @@ describe("GET /api/plugins/dashboard-views", () => {
},
},
];
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockReturnValue(mockViews);
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValue(mockViews);
const res = await performGet(buildApp(), "/api/plugins/dashboard-views");
@@ -1272,8 +1272,40 @@ describe("GET /api/plugins/dashboard-views", () => {
expect(res.body).toEqual(mockViews);
});
it("awaits refreshed loader dashboard-view metadata before responding", async () => {
const refreshedViews = [
{
pluginId: "generic-nav-plugin",
view: {
viewId: "overview",
label: "Current Overview",
componentPath: "./dashboard/overview.js",
icon: "Boxes",
placement: "primary",
},
},
{
pluginId: "generic-reports-plugin",
view: {
viewId: "reports",
label: "Current Reports",
componentPath: "./dashboard/reports.js",
icon: "FileText",
placement: "more",
},
},
];
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValue(refreshedViews);
const res = await performGet(buildApp(), "/api/plugins/dashboard-views");
expect(res.status).toBe(200);
expect(pluginLoader.getPluginDashboardViews).toHaveBeenCalledTimes(1);
expect(res.body).toEqual(refreshedViews);
});
it("returns exactly pluginLoader dashboard-view entries (no synthesized plugin rows)", async () => {
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockReturnValue([
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValue([
{
pluginId: "with-view",
view: {
@@ -1295,7 +1327,7 @@ describe("GET /api/plugins/dashboard-views", () => {
});
it("keeps dashboard-views payload separate from ui-slots payload", async () => {
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockReturnValue([
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValue([
{
pluginId: "fusion-plugin-roadmap",
view: {

View File

@@ -3432,7 +3432,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
* Returns aggregated array of { pluginId, view } objects.
*/
router.get("/plugins/dashboard-views", async (_req: Request, res: Response) => {
const views = options?.pluginLoader?.getPluginDashboardViews() ?? [];
const views = await options?.pluginLoader?.getPluginDashboardViews() ?? [];
res.json(views);
});