feat(FN-3154): add plugin schema hooks, nav placement, and progress preserv

This merge introduces three major features: task reset now preserves existing progress with explicit user confirmation (FN-3185), the engine's soft-pause/unpause behavior is restored and documented (FN-3201), and the plugin system gains schema hook aggregation with new navigation placement and icons

Fusion-Task-Id: FN-3154
This commit is contained in:
Fusion
2026-05-02 10:41:22 -07:00
committed by gsxdsm
parent 44cc899eb9
commit bed7f3d325
12 changed files with 269 additions and 16 deletions

View File

@@ -1344,6 +1344,26 @@ describe("PluginLoader", () => {
expect(loader.getPluginDashboardViews()).toEqual([]);
});
it("returns aggregated views from a single plugin", async () => {
await pluginStore.init();
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
(loader as any).plugins.set("views-a", {
manifest: makeManifest({ id: "views-a" }),
state: "started",
hooks: {},
dashboardViews: [
{ viewId: "graph", label: "Graph", componentPath: "./graph.js", placement: "more" },
{ viewId: "timeline", label: "Timeline", componentPath: "./timeline.js", placement: "overflow" },
],
} as FusionPlugin);
const views = loader.getPluginDashboardViews();
expect(views.map((entry) => entry.pluginId + ":" + entry.view.viewId)).toEqual([
"views-a:graph",
"views-a:timeline",
]);
});
it("aggregates dashboard views from multiple plugins and keeps uiSlots separate", async () => {
await pluginStore.init();
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
@@ -1371,6 +1391,48 @@ describe("PluginLoader", () => {
});
});
describe("getPluginSchemaInitHooks", () => {
it("returns empty array when no plugins define onSchemaInit", async () => {
await pluginStore.init();
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
(loader as any).plugins.set("no-hook", {
manifest: makeManifest({ id: "no-hook" }),
state: "started",
hooks: {},
} as FusionPlugin);
expect(loader.getPluginSchemaInitHooks()).toEqual([]);
});
it("returns hooks only from plugins that define onSchemaInit", async () => {
await pluginStore.init();
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
const hookA = async () => {};
const hookB = () => {};
(loader as any).plugins.set("schema-a", {
manifest: makeManifest({ id: "schema-a" }),
state: "started",
hooks: { onSchemaInit: hookA },
} as FusionPlugin);
(loader as any).plugins.set("schema-b", {
manifest: makeManifest({ id: "schema-b" }),
state: "started",
hooks: { onLoad: async () => {} },
} as FusionPlugin);
(loader as any).plugins.set("schema-c", {
manifest: makeManifest({ id: "schema-c" }),
state: "started",
hooks: { onSchemaInit: hookB },
} as FusionPlugin);
const hooks = loader.getPluginSchemaInitHooks();
expect(hooks.map((entry) => entry.pluginId)).toEqual(["schema-a", "schema-c"]);
expect(hooks[0]?.hook).toBe(hookA);
expect(hooks[1]?.hook).toBe(hookB);
});
});
// ── getPluginRuntimes ─────────────────────────────────────────────
describe("getPluginRuntimes", () => {

View File

@@ -779,6 +779,24 @@ describe("PluginUiSlotDefinition", () => {
// ── FusionPlugin with uiSlots ──────────────────────────────────────────
describe("PluginDashboardViewDefinition", () => {
it("accepts a valid PluginDashboardViewDefinition with optional fields", () => {
const view = {
viewId: "roadmap-planner",
label: "Roadmap Planner",
componentPath: "./views/RoadmapPlanner.js",
icon: "Map",
order: 10,
placement: "overflow",
description: "Plan milestones and slices",
};
expect(view.viewId).toBe("roadmap-planner");
expect(view.placement).toBe("overflow");
expect(view.description).toContain("milestones");
});
});
describe("FusionPlugin with uiSlots", () => {
it("accepts a FusionPlugin with uiSlots array", () => {
const plugin = {
@@ -818,6 +836,27 @@ describe("FusionPlugin with uiSlots", () => {
expect((plugin as any).uiSlots).toBeUndefined();
});
it("accepts a FusionPlugin with dashboardViews and onSchemaInit hook", () => {
const plugin: FusionPlugin = {
manifest: { id: "test-plugin", name: "Test Plugin", version: "1.0.0" },
state: "started",
hooks: {
onSchemaInit: async () => {},
},
dashboardViews: [
{
viewId: "dependencies",
label: "Dependencies",
componentPath: "./views/Dependencies.js",
placement: "primary",
},
],
};
expect(plugin.hooks.onSchemaInit).toBeTypeOf("function");
expect(plugin.dashboardViews?.[0]?.viewId).toBe("dependencies");
});
});
// ── FusionPlugin with runtime ──────────────────────────────────────────

View File

@@ -131,6 +131,7 @@ export type {
PluginSettingType,
PluginOnLoad,
PluginOnUnload,
PluginOnSchemaInit,
PluginOnTaskCreated,
PluginOnTaskMoved,
PluginOnTaskCompleted,

View File

@@ -23,6 +23,7 @@ import type {
PluginRouteDefinition,
PluginUiSlotDefinition,
PluginDashboardViewDefinition,
PluginOnSchemaInit,
PluginRuntimeRegistration,
PluginInstallation,
PluginSkillContribution,
@@ -805,6 +806,19 @@ export class PluginLoader extends EventEmitter<{
return views;
}
/**
* Get all schema initialization hooks from loaded plugins.
*/
getPluginSchemaInitHooks(): Array<{ pluginId: string; hook: PluginOnSchemaInit }> {
const hooks: Array<{ pluginId: string; hook: PluginOnSchemaInit }> = [];
for (const [pluginId, plugin] of this.plugins) {
if (plugin.hooks.onSchemaInit) {
hooks.push({ pluginId, hook: plugin.hooks.onSchemaInit });
}
}
return hooks;
}
/**
* Get all runtime registrations from loaded plugins.
* Returns plugin ownership metadata along with the runtime registration.

View File

@@ -11,6 +11,7 @@
* - PluginInstallation: persisted plugin record
*/
import type { Database } from "./db.js";
import type { TaskStore } from "./store.js";
import type { Task, WorkflowStepMode, WorkflowStepToolMode } from "./types.js";
@@ -108,6 +109,8 @@ export interface PluginLogger {
export type PluginOnLoad = (ctx: PluginContext) => Promise<void> | void;
/** Lifecycle hook: called when plugin is unloaded */
export type PluginOnUnload = () => Promise<void> | void;
/** Lifecycle hook: called during database schema initialization */
export type PluginOnSchemaInit = (db: Database) => Promise<void> | void;
/** Lifecycle hook: called when a task is created */
export type PluginOnTaskCreated = (task: Task, ctx: PluginContext) => Promise<void> | void;
/** Lifecycle hook: called when a task moves between columns */
@@ -222,7 +225,9 @@ export interface PluginDashboardViewDefinition {
/** Optional sort order for nav presentation. Lower numbers appear first. */
order?: number;
/** Preferred navigation placement for this top-level view. */
placement?: "primary" | "more";
placement?: "primary" | "overflow" | "more";
/** Optional short description used by navigation/help UI. */
description?: string;
}
// ── Plugin Runtimes ─────────────────────────────────────────────────
@@ -398,6 +403,7 @@ export interface FusionPlugin {
onTaskMoved?: PluginOnTaskMoved;
onTaskCompleted?: PluginOnTaskCompleted;
onError?: PluginOnError;
onSchemaInit?: PluginOnSchemaInit;
};
tools?: PluginToolDefinition[];
routes?: PluginRouteDefinition[];