diff --git a/.changeset/fn-7397-plugin-hook-context.md b/.changeset/fn-7397-plugin-hook-context.md new file mode 100644 index 0000000000..68a725f555 --- /dev/null +++ b/.changeset/fn-7397-plugin-hook-context.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Ensure task lifecycle plugins receive runtime context during completion hooks. +category: fix +dev: PluginLoader now appends PluginContext to task lifecycle hook invocations when callers provide only task event args. diff --git a/packages/core/src/__tests__/plugin-loader.test.ts b/packages/core/src/__tests__/plugin-loader.test.ts index 440a0a5911..65acd2e68e 100644 --- a/packages/core/src/__tests__/plugin-loader.test.ts +++ b/packages/core/src/__tests__/plugin-loader.test.ts @@ -20,7 +20,7 @@ vi.mock("@earendil-works/pi-ai", () => ({ })); import { PluginStore } from "../plugin-store.js"; import { setCreateAiSessionFactory } from "../ai-engine-loader.js"; -import type { CreateAiSessionOptions, FusionPlugin, PluginManifest } from "../plugin-types.js"; +import type { CreateAiSessionOptions, FusionPlugin, PluginContext, PluginManifest } from "../plugin-types.js"; // Test plugin manifest function makeManifest(overrides: Partial = {}): PluginManifest { @@ -82,6 +82,8 @@ async function writePluginWithHooks( onLoad?: string; onUnload?: string; onTaskCreated?: string; + onTaskMoved?: string; + onTaskCompleted?: string; onError?: string; }, manifest: PluginManifest, @@ -1135,6 +1137,54 @@ export default plugin; expect(hookB).toHaveBeenCalledTimes(1); }); + it("passes PluginContext to task lifecycle hooks invoked through the loader", async () => { + await pluginStore.init(); + await pluginStore.registerPlugin({ + manifest: makeManifest({ id: "context-hook" }), + path: "/virtual/context-hook.js", + settings: { mode: "runtime" }, + }); + + const onTaskCreated = vi.fn(); + const onTaskMoved = vi.fn(); + const onTaskCompleted = vi.fn(); + const loader = new PluginLoader({ + pluginStore, + taskStore: mockTaskStore, + }); + (loader as any).plugins.set("context-hook", { + manifest: makeManifest({ id: "context-hook" }), + state: "started", + hooks: { onTaskCreated, onTaskMoved, onTaskCompleted }, + tools: [], + routes: [], + } as FusionPlugin); + + const task = { id: "FN-001" } as any; + await loader.invokeHook("onTaskCreated", task); + await loader.invokeHook("onTaskMoved", task, "todo", "done"); + await loader.invokeHook("onTaskCompleted", task); + + const createdCtx = onTaskCreated.mock.calls[0]?.[1] as PluginContext | undefined; + const movedCtx = onTaskMoved.mock.calls[0]?.[3] as PluginContext | undefined; + const completedCtx = onTaskCompleted.mock.calls[0]?.[1] as PluginContext | undefined; + + for (const hookCtx of [createdCtx, movedCtx, completedCtx]) { + expect(hookCtx).toMatchObject({ + pluginId: "context-hook", + taskStore: mockTaskStore, + settings: { mode: "runtime" }, + }); + expect(hookCtx?.logger).toEqual(expect.objectContaining({ + info: expect.any(Function), + warn: expect.any(Function), + error: expect.any(Function), + debug: expect.any(Function), + })); + expect(hookCtx?.emitEvent).toEqual(expect.any(Function)); + } + }); + it("continues when one plugin's hook fails", async () => { await pluginStore.init(); diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index 3e0d26644b..27e4a39cf8 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -902,14 +902,44 @@ export class PluginLoader extends EventEmitter<{ const hook = plugin.hooks[hookName]; if (!hook) return; - const fn = hook as (...args: unknown[]) => unknown; - const result = fn(...args); + const result = fn(...await this.withLifecycleHookContext(plugin, hookName, args)); if (result instanceof Promise) { await result; } } + private async withLifecycleHookContext( + plugin: FusionPlugin, + hookName: keyof FusionPlugin["hooks"], + args: unknown[], + ): Promise { + if (!this.isTaskLifecycleHook(hookName) || this.hasPluginContext(args.at(-1))) { + return args; + } + + /* + FNXC:PluginHooks 2026-07-01-13:36: + Runtime task lifecycle hooks are invoked from fire-and-forget TaskStore event bridges, but the public hook contract still requires a per-plugin PluginContext. Append the context in PluginLoader so all runtime callers keep the fast raw event-argument path while plugins consistently receive taskStore, settings, logger, and emitEvent. + */ + return [...args, await this.createContext(plugin)]; + } + + private isTaskLifecycleHook(hookName: keyof FusionPlugin["hooks"]): boolean { + return hookName === "onTaskCreated" || hookName === "onTaskMoved" || hookName === "onTaskCompleted"; + } + + private hasPluginContext(value: unknown): value is PluginContext { + return Boolean( + value + && typeof value === "object" + && "taskStore" in value + && "settings" in value + && "logger" in value + && "emitEvent" in value, + ); + } + async checkPluginSetup(pluginId: string): Promise { const plugin = this.plugins.get(pluginId); if (!plugin) { diff --git a/packages/engine/src/__tests__/plugin-runner.test.ts b/packages/engine/src/__tests__/plugin-runner.test.ts index 2adceb964c..f8913404c2 100644 --- a/packages/engine/src/__tests__/plugin-runner.test.ts +++ b/packages/engine/src/__tests__/plugin-runner.test.ts @@ -1478,8 +1478,20 @@ describe("PluginRunner", () => { ); }); - it("should invoke onTaskCompleted when task moves to done", async () => { - mockPluginLoader.invokeHook = vi.fn(); + it("should invoke onTaskCompleted through the loader seam when task moves to done", async () => { + const completedHook = vi.fn(); + const runtimeCtx = { + pluginId: "test-plugin", + taskStore: mockTaskStore, + settings: {}, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + emitEvent: vi.fn(), + }; + mockPluginLoader.invokeHook = vi.fn(async (hookName: keyof FusionPlugin["hooks"], ...args: unknown[]) => { + if (hookName === "onTaskCompleted") { + completedHook(args[0], runtimeCtx); + } + }); await pluginRunner.init(); // Find the task:moved handler @@ -1500,6 +1512,15 @@ describe("PluginRunner", () => { "onTaskCompleted", mockTask ); + expect(completedHook).toHaveBeenCalledWith( + mockTask, + expect.objectContaining({ + taskStore: mockTaskStore, + settings: {}, + logger: expect.any(Object), + emitEvent: expect.any(Function), + }) + ); }); it("should NOT invoke onTaskCompleted when task moves elsewhere", async () => { diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/sync.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/sync.test.ts index efefae4947..76987fdf3e 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/sync.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/sync.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { InteractiveAiSessionEvent, PluginContext, Task } from "@fusion/core"; -import { TaskStore } from "@fusion/core"; +import { PluginLoader, PluginStore, TaskStore } from "@fusion/core"; import plugin, { CeOrchestrator, CE_PLUGIN_ID, @@ -137,6 +137,33 @@ describe("U8 inbound hooks (board → pipeline)", () => { // The hook awaits NOTHING heavy; it returns synchronously-ish. expect(Date.now() - start).toBeLessThan(1000); }); + + it("runtime loader invocation gives onTaskCompleted a context and enqueues task_completed sync", async () => { + const pluginStore = new PluginStore(rootDir, { inMemoryDb: true, centralGlobalDir: rootDir }); + await pluginStore.init(); + await pluginStore.registerPlugin({ + manifest: plugin.manifest, + path: join(rootDir, "compound-engineering.js"), + settings: { reconcileOnHooks: false }, + }); + const loader = new PluginLoader({ pluginStore, taskStore }); + (loader as unknown as { plugins: Map }).plugins.set(CE_PLUGIN_ID, plugin); + + const { task } = await landPipeline("plan"); + await moveTo(task.id, "done"); + await expect(loader.invokeHook("onTaskCompleted", { ...task, column: "done" })).resolves.toBeUndefined(); + + const installed = await pluginStore.getPlugin(CE_PLUGIN_ID); + expect(installed.state).not.toBe("error"); + const pending = getCePipelineStore(ctx).listPendingSync(); + expect(pending).toContainEqual(expect.objectContaining({ + taskId: task.id, + reason: "task_completed", + fromColumn: null, + toColumn: "done", + processedAt: null, + })); + }); }); describe("U8 reconciler (convergence + outbound)", () => {