fix(FN-3794): pass unload context and isolate WhatsApp sessions per project

- Extend PluginOnUnload to receive runtime context and wire ctx through plugin-loader unload hooks
- Scope WhatsApp chat plugin connections by project root to avoid cross-project session leakage
- Update plugin authoring docs and add a patch changeset for @runfusion/fusion
- Align plugin test suites across WhatsApp and example/runtime plugins with the new onUnload context contract

Fusion-Task-Id: FN-3794
This commit is contained in:
Fusion
2026-05-09 00:50:47 -07:00
committed by gsxdsm
parent aa56ef0f8a
commit 7d20a348d8
11 changed files with 166 additions and 18 deletions

View File

@@ -129,6 +129,8 @@ function droidPluginModulePath(): string {
// Mock TaskStore for testing
const mockTaskStore = {
logActivity: vi.fn(),
getRootDir: () => "/tmp/plugin-loader-test-root",
getPluginStore: vi.fn(),
} as any;
type MockStructuredLogger = {
@@ -785,6 +787,42 @@ export default plugin;
expect(loader.isPluginLoaded("remove-test")).toBe(false);
});
it("passes plugin context to onUnload", async () => {
await pluginStore.init();
const plugin = makePlugin(makeManifest({ id: "stop-context-test" }));
const pluginDir = join(rootDir, "plugins");
const pluginPath = await writePluginWithHooks(
pluginDir,
"stop-context.js",
{
onUnload:
"(ctx => { globalThis.__pluginUnloadCtx = { pluginId: ctx.pluginId, taskStore: ctx.taskStore }; })",
},
plugin.manifest,
);
await pluginStore.registerPlugin({
manifest: plugin.manifest,
path: pluginPath,
});
const loader = new PluginLoader({
pluginStore,
taskStore: mockTaskStore,
});
await loader.loadPlugin("stop-context-test");
await loader.stopPlugin("stop-context-test");
const unloadCtx = (globalThis as { __pluginUnloadCtx?: { pluginId: string; taskStore: unknown } })
.__pluginUnloadCtx;
expect(unloadCtx).toBeDefined();
expect(unloadCtx?.pluginId).toBe("stop-context-test");
expect(unloadCtx?.taskStore).toBe(mockTaskStore);
delete (globalThis as { __pluginUnloadCtx?: unknown }).__pluginUnloadCtx;
});
it("no-ops for non-loaded plugin", async () => {
await pluginStore.init();

View File

@@ -374,8 +374,9 @@ export class PluginLoader extends EventEmitter<{
// Call onUnload with timeout
try {
const ctx = await this.createContext(oldPlugin);
await this.withTimeout(
this.safeCallHook(oldPlugin, "onUnload", []),
this.safeCallHook(oldPlugin, "onUnload", [ctx]),
timeoutMs,
`onUnload timeout for ${pluginId}`,
);
@@ -645,8 +646,9 @@ export class PluginLoader extends EventEmitter<{
try {
// Call onUnload hook
const ctx = await this.createContext(plugin);
await this.withTimeout(
this.safeCallHook(plugin, "onUnload", []),
this.safeCallHook(plugin, "onUnload", [ctx]),
5000,
`onUnload timeout for ${pluginId}`,
);

View File

@@ -154,7 +154,7 @@ export interface PluginLogger {
/** Lifecycle hook: called when plugin is loaded */
export type PluginOnLoad = (ctx: PluginContext) => Promise<void> | void;
/** Lifecycle hook: called when plugin is unloaded */
export type PluginOnUnload = () => Promise<void> | void;
export type PluginOnUnload = (ctx: PluginContext) => 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 */