FN-7397: pass plugin context to lifecycle hooks
Ensure task lifecycle plugin hooks receive a PluginContext when fired through runtime event bridges. - Append the plugin context for task-created, task-moved, and task-completed hooks when callers only provide raw lifecycle arguments. - Preserve existing explicit-context hook calls and add coverage for loader and runner lifecycle dispatch paths. - Cover Compound Engineering sync completion handling so taskStore is available in onTaskCompleted. Files changed: .changeset/fn-7397-plugin-hook-context.md | 7 +++ packages/core/src/__tests__/plugin-loader.test.ts | 52 +++++++++++++++++++++- packages/core/src/plugin-loader.ts | 34 +++++++++++++- .../engine/src/__tests__/plugin-runner.test.ts | 25 ++++++++++- .../src/__tests__/sync.test.ts | 29 +++++++++++- 5 files changed, 141 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-7397 Fusion-Task-Lineage: 7661714c-cf2a-4f4d-bec1-fc6bc11c0613 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7397-plugin-hook-context.md
Normal file
7
.changeset/fn-7397-plugin-hook-context.md
Normal file
@@ -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.
|
||||
@@ -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> = {}): 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();
|
||||
|
||||
|
||||
@@ -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<unknown[]> {
|
||||
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<PluginSetupCheckResult> {
|
||||
const plugin = this.plugins.get(pluginId);
|
||||
if (!plugin) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<string, typeof plugin> }).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)", () => {
|
||||
|
||||
Reference in New Issue
Block a user