// @vitest-environment node // // PLUGIN-CONTRIBUTED TRAITS SUITE (U8, R6/R15/R22, KTD-2/KTD-7). // // Asserts against REAL engine wiring per the branch-group dead-wiring lesson: // - real TaskStore (in-memory sqlite) with the workflowColumns flag ON, // - real core TraitRegistry (fresh per test) + built-ins, // - real PluginLoader/PluginStore loading a JSON plugin module that declares // `traits`, // - real plugin-trait adapter (registration / gate eval / degrade / dependents). // // No engine methods are mocked. The only injected fake is the custom-node // RUNNER (the prompt-session/script machinery), which is the documented seam the // executor wires — we substitute a deterministic verdict producer so the test // stays fast and offline. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { execSync } from "node:child_process"; import { TaskStore, PluginStore, PluginLoader, getTraitRegistry, __resetTraitRegistryForTests, registerBuiltinTraits, registerDefaultWorkflowHooks, __resetDefaultWorkflowHooksForTests, validatePluginTraitContribution, type WorkflowIr, type PluginTraitContribution, } from "@fusion/core"; import { registerPluginTraits, degradePluginTraits, findLivePluginTraitDependents, evaluatePluginGate, pluginTraitRegistryId, PluginTraitHasDependentsError, } from "../plugin-trait-adapter.js"; import type { WorkflowCustomNodeRunner } from "../workflow-node-handlers.js"; import type { WorkflowNodeResult } from "../workflow-graph-executor.js"; function git(cwd: string, args: string): void { execSync(`git ${args}`, { cwd, stdio: "ignore" }); } /** Fresh registry with built-ins + default-workflow hooks re-wired (so the * default-workflow move-effect hooks aren't degraded to no-ops mid-suite). */ function freshRegistry(): void { __resetTraitRegistryForTests(); __resetDefaultWorkflowHooksForTests(); registerBuiltinTraits(); registerDefaultWorkflowHooks(); } /** Raw column placement (bypasses adjacency validation for setup). */ function setColumn(store: TaskStore, taskId: string, column: string): void { const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db; db.prepare('UPDATE tasks SET "column" = ?, "columnMovedAt" = ? WHERE id = ?').run( column, new Date().toISOString(), taskId, ); } function setSelection(store: TaskStore, taskId: string, workflowId: string): void { const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db; db.prepare( `INSERT INTO task_workflow_selection (taskId, workflowId, stepIds, updatedAt) VALUES (?, ?, '[]', ?) ON CONFLICT(taskId) DO UPDATE SET workflowId = excluded.workflowId, updatedAt = excluded.updatedAt`, ).run(taskId, workflowId, new Date().toISOString()); } function readTransitionPending(store: TaskStore, taskId: string): string | null { const db = (store as unknown as { db: { prepare: (s: string) => { get: (...a: unknown[]) => unknown } } }).db; const row = db.prepare("SELECT transitionPending FROM tasks WHERE id = ?").get(taskId) as | { transitionPending: string | null } | undefined; return row?.transitionPending ?? null; } /** * A custom v2 workflow with three ordered columns. `gate-col` carries the given * plugin trait id; order-derived adjacency lets a card move * `intake-col → gate-col`. */ function customWorkflowIr(pluginTraitId: string, opts?: { traitConfig?: Record }): WorkflowIr { return { version: "v2", name: "Custom", columns: [ { id: "intake-col", name: "Intake", traits: [{ trait: "intake" }] }, { id: "gate-col", name: "Gate", traits: [{ trait: pluginTraitId, config: opts?.traitConfig }], }, { id: "done-col", name: "Done", traits: [{ trait: "complete" }] }, ], nodes: [ { id: "start", kind: "start", column: "intake-col" }, { id: "end", kind: "end", column: "done-col" }, ], edges: [{ from: "start", to: "end" }], } as WorkflowIr; } const PASS_RUNNER: WorkflowCustomNodeRunner = async (): Promise => ({ outcome: "success", value: "passed", }); const FAIL_RUNNER: WorkflowCustomNodeRunner = async (): Promise => ({ outcome: "failure", value: "blocked", }); describe("U8 plugin trait contribution validation (R22, schemaVersion)", () => { it("rejects a malformed trait manifest (missing schemaVersion / name)", () => { const errors = validatePluginTraitContribution({ traitId: "x" }); expect(errors.some((e) => e.includes("schemaVersion is required"))).toBe(true); expect(errors.some((e) => e.includes("name is required"))).toBe(true); }); it("rejects a sync `guard` hook key (built-in-only, R22)", () => { const errors = validatePluginTraitContribution({ traitId: "g", name: "G", schemaVersion: 1, hooks: { guard: true }, }); expect(errors.some((e) => e.includes("hooks.guard"))).toBe(true); }); it("rejects a restricted flag (complete / archived, R22)", () => { const completeErr = validatePluginTraitContribution({ traitId: "c", name: "C", schemaVersion: 1, flags: { complete: true }, }); expect(completeErr.some((e) => e.includes("flags.complete"))).toBe(true); const archivedErr = validatePluginTraitContribution({ traitId: "a", name: "A", schemaVersion: 1, flags: { archived: true }, }); expect(archivedErr.some((e) => e.includes("flags.archived"))).toBe(true); }); it("rejects a wrong schemaVersion (versioned extension contract)", () => { const errors = validatePluginTraitContribution({ traitId: "v", name: "V", schemaVersion: 2 as unknown as 1 }); expect(errors.some((e) => e.includes("schemaVersion must be 1"))).toBe(true); }); it("accepts a valid async-only gate contribution", () => { const errors = validatePluginTraitContribution({ traitId: "approval", name: "Approval gate", schemaVersion: 1, flags: { gate: true }, hooks: { gate: { mode: "prompt", prompt: "Approve?", gateMode: "blocking" } }, }); expect(errors).toEqual([]); }); }); describe("U8 registry resolution (valid trait resolves like a built-in)", () => { beforeEach(() => { freshRegistry(); }); afterEach(() => { __resetTraitRegistryForTests(); }); it("registers a plugin trait under a plugin-namespaced id and resolves through the same lookup", () => { const registry = getTraitRegistry(); const contribution: PluginTraitContribution = { traitId: "approval", name: "Approval gate", schemaVersion: 1, flags: { gate: true }, hooks: { gate: { mode: "prompt", prompt: "Approve?", gateMode: "blocking" } }, }; const ids = registerPluginTraits({ registry, pluginId: "gate-plugin", contributions: [contribution], runCustomNode: PASS_RUNNER }); const id = pluginTraitRegistryId("gate-plugin", "approval"); expect(ids).toEqual([id]); // Same lookup path as a built-in. const def = registry.getTrait(id); expect(def?.flags.gate).toBe(true); expect(def?.builtin).toBeFalsy(); // Built-in still resolvable through the same registry. expect(registry.getTrait("complete")?.flags.complete).toBe(true); // The gate hook impl is registered (not a missing-impl degrade). const resolved = registry.resolveTraitHook(id, "gate"); expect(resolved.impl).toBeTypeOf("function"); expect(resolved.warning).toBeUndefined(); }); it("registry rejects a restricted-flag plugin trait as a backstop (R22)", () => { const registry = getTraitRegistry(); // The adapter builds a non-builtin definition; the registry enforces R22. const bad: PluginTraitContribution = { traitId: "sneaky", name: "Sneaky", schemaVersion: 1, // @ts-expect-error — restricted flag deliberately set to prove the backstop. flags: { complete: true }, }; expect(() => registerPluginTraits({ registry, pluginId: "p", contributions: [bad], runCustomNode: PASS_RUNNER }), ).toThrow(/restricted flag/i); }); }); describe("U8 gate evaluation (blocking fails closed; advisory allows)", () => { it("blocking gate: a failure verdict does not allow", async () => { const result = await evaluatePluginGate({ traitRegistryId: "plugin:gate-plugin:approval", descriptor: { mode: "prompt", prompt: "Approve?", gateMode: "blocking" }, task: { id: "T1" } as never, runCustomNode: FAIL_RUNNER, }); expect(result.outcome).toBe("failure"); }); it("blocking gate: a pass verdict allows", async () => { const result = await evaluatePluginGate({ traitRegistryId: "plugin:gate-plugin:approval", descriptor: { mode: "prompt", prompt: "Approve?", gateMode: "blocking" }, task: { id: "T1" } as never, runCustomNode: PASS_RUNNER, }); expect(result.outcome).toBe("success"); }); it("advisory gate: the handler reports the raw verdict (store layer record-and-allows)", async () => { // evaluatePluginGate returns the raw runner outcome; the advisory // "record-and-allow" decision is made at the store guard (see the store // re-check suite below, which proves an advisory column move commits). const result = await evaluatePluginGate({ traitRegistryId: "plugin:gate-plugin:approval", descriptor: { mode: "prompt", prompt: "FYI", gateMode: "advisory" }, task: { id: "T1" } as never, runCustomNode: FAIL_RUNNER, }); expect(result.outcome).toBe("failure"); }); }); describe("U8 store gate re-check (pre-evaluated verdict, KTD-2)", () => { let rootDir = ""; let store: TaskStore; const gateTraitId = pluginTraitRegistryId("gate-plugin", "approval"); beforeEach(async () => { freshRegistry(); const registry = getTraitRegistry(); registry.register({ id: gateTraitId, name: "Approval gate", flags: { gate: true }, hooks: { gate: true }, builtin: false, }); // A LIVE gate hook impl (so the store enforces the recorded verdict rather // than treating the gate as a degraded/passive no-op). registry.registerTraitHookImpl(gateTraitId, "gate", () => undefined); rootDir = mkdtempSync(join(tmpdir(), "u8-plugin-traits-")); git(rootDir, "init -b main"); git(rootDir, "config user.name 'Fusion'"); git(rootDir, "config user.email 'hi@runfusion.ai'"); writeFileSync(join(rootDir, "README.md"), "root\n"); git(rootDir, "add README.md"); git(rootDir, "commit -m init"); store = new TaskStore(rootDir, undefined, { inMemoryDb: false }); await store.init(); await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); }); afterEach(() => { try { store?.close(); } catch { /* ignore */ } if (rootDir) rmSync(rootDir, { recursive: true, force: true }); __resetTraitRegistryForTests(); vi.clearAllMocks(); }); async function seedCardInGateWorkflow(config?: Record): Promise { const def = await store.createWorkflowDefinition({ name: "Gate WF", ir: customWorkflowIr(gateTraitId, { traitConfig: config }), }); const task = await store.createTask({ description: "card" }); setSelection(store, task.id, def.id); setColumn(store, task.id, "intake-col"); return task.id; } it("blocking gate with NO recorded verdict rejects the move (fail closed)", async () => { const id = await seedCardInGateWorkflow({ gateMode: "blocking" }); await expect( store.moveTask(id, "gate-col", { moveSource: "user" }), ).rejects.toThrow(/has not been evaluated|did not pass/); expect((await store.getTask(id)).column).toBe("intake-col"); }); it("blocking gate with a recorded ALLOW verdict permits the move", async () => { const id = await seedCardInGateWorkflow({ gateMode: "blocking" }); store.recordPluginGateVerdict(id, "gate-col", { traitId: gateTraitId, allow: true, gateMode: "blocking", }); const moved = await store.moveTask(id, "gate-col", { moveSource: "user" }); expect(moved.column).toBe("gate-col"); }); it("blocking gate with a recorded DENY verdict rejects the move (typed rejection)", async () => { const id = await seedCardInGateWorkflow({ gateMode: "blocking" }); store.recordPluginGateVerdict(id, "gate-col", { traitId: gateTraitId, allow: false, gateMode: "blocking", detail: "reviewer rejected", }); await expect( store.moveTask(id, "gate-col", { moveSource: "user" }), ).rejects.toThrow(/reviewer rejected/); expect((await store.getTask(id)).column).toBe("intake-col"); }); it("advisory gate allows the move even without a verdict (record-and-allow)", async () => { const id = await seedCardInGateWorkflow({ gateMode: "advisory" }); const moved = await store.moveTask(id, "gate-col", { moveSource: "user" }); expect(moved.column).toBe("gate-col"); }); it("engine-sourced move bypasses the plugin gate (KTD-9)", async () => { const id = await seedCardInGateWorkflow({ gateMode: "blocking" }); // No verdict recorded; an engine move bypasses guards entirely. const moved = await store.moveTask(id, "gate-col", { moveSource: "engine" }); expect(moved.column).toBe("gate-col"); }); }); describe("U8 onEnter hook degradation (card stays, marker cleared, no wedge)", () => { let rootDir = ""; let store: TaskStore; const traitId = pluginTraitRegistryId("notify-plugin", "boom"); beforeEach(async () => { freshRegistry(); // A plugin trait with an onEnter hook whose impl THROWS. const registry = getTraitRegistry(); registry.register({ id: traitId, name: "Boom", flags: { notify: true }, hooks: { onEnter: true }, builtin: false, }); registry.registerTraitHookImpl(traitId, "onEnter", () => { throw new Error("plugin onEnter blew up"); }); rootDir = mkdtempSync(join(tmpdir(), "u8-onenter-")); git(rootDir, "init -b main"); git(rootDir, "config user.name 'Fusion'"); git(rootDir, "config user.email 'hi@runfusion.ai'"); writeFileSync(join(rootDir, "README.md"), "root\n"); git(rootDir, "add README.md"); git(rootDir, "commit -m init"); store = new TaskStore(rootDir, undefined, { inMemoryDb: false }); await store.init(); await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); }); afterEach(() => { try { store?.close(); } catch { /* ignore */ } if (rootDir) rmSync(rootDir, { recursive: true, force: true }); __resetTraitRegistryForTests(); }); it("a throwing plugin onEnter does NOT strand the card or wedge the lock", async () => { // gate-col carries the throwing onEnter trait; move there, then verify a // subsequent move still succeeds (the lock was not wedged) and the // transitionPending marker did not stick. const def = await store.createWorkflowDefinition({ name: "Boom WF", ir: customWorkflowIr(traitId), }); const task = await store.createTask({ description: "card" }); setSelection(store, task.id, def.id); setColumn(store, task.id, "intake-col"); // Degraded-not-stranded (KTD-2/R15): the move commits the column change in // its transaction; plugin post-commit hooks are isolated from the move's // success path (a throwing onEnter cannot fail the move, strand the card, or // wedge the lock). The card lands in gate-col regardless of the plugin hook. const moved = await store.moveTask(task.id, "gate-col", { moveSource: "user" }); expect(moved.column).toBe("gate-col"); // The marker was cleared post-commit — not left dangling. expect(readTransitionPending(store, task.id)).toBeNull(); // The lock is not wedged: a follow-up move proceeds. const back = await store.moveTask(task.id, "intake-col", { moveSource: "user" }); expect(back.column).toBe("intake-col"); }); }); describe("Residual C: plugin onEnter/onExit are INVOKED on the post-commit path", () => { let rootDir = ""; let store: TaskStore; const enterTrait = pluginTraitRegistryId("notify-plugin", "enter"); const exitTrait = pluginTraitRegistryId("notify-plugin", "exit"); let enterCalls = 0; let exitCalls = 0; beforeEach(async () => { freshRegistry(); enterCalls = 0; exitCalls = 0; const registry = getTraitRegistry(); registry.register({ id: enterTrait, name: "Enter", flags: { notify: true }, hooks: { onEnter: true }, builtin: false }); registry.register({ id: exitTrait, name: "Exit", flags: { notify: true }, hooks: { onExit: true }, builtin: false }); registry.registerTraitHookImpl(enterTrait, "onEnter", () => { enterCalls += 1; }); registry.registerTraitHookImpl(exitTrait, "onExit", () => { exitCalls += 1; }); rootDir = mkdtempSync(join(tmpdir(), "u8-cohooks-")); git(rootDir, "init -b main"); git(rootDir, "config user.name 'Fusion'"); git(rootDir, "config user.email 'hi@runfusion.ai'"); writeFileSync(join(rootDir, "README.md"), "root\n"); git(rootDir, "add README.md"); git(rootDir, "commit -m init"); store = new TaskStore(rootDir, undefined, { inMemoryDb: false }); await store.init(); await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); }); afterEach(() => { try { store?.close(); } catch { /* ignore */ } if (rootDir) rmSync(rootDir, { recursive: true, force: true }); __resetTraitRegistryForTests(); }); it("onEnter fires for the to-column's plugin trait; onExit fires for the from-column's", async () => { // Workflow: intake-col(exit trait) → gate-col(enter trait) → done-col. const ir = { version: "v2", name: "CoHooks", columns: [ { id: "intake-col", name: "Intake", traits: [{ trait: "intake" }, { trait: exitTrait }] }, { id: "gate-col", name: "Gate", traits: [{ trait: enterTrait }] }, { id: "done-col", name: "Done", traits: [{ trait: "complete" }] }, ], nodes: [ { id: "start", kind: "start", column: "intake-col" }, { id: "end", kind: "end", column: "done-col" }, ], edges: [{ from: "start", to: "end" }], } as WorkflowIr; const def = await store.createWorkflowDefinition({ name: "CoHooks", ir }); const task = await store.createTask({ description: "card" }); setSelection(store, task.id, def.id); setColumn(store, task.id, "intake-col"); const moved = await store.moveTask(task.id, "gate-col", { moveSource: "user" }); expect(moved.column).toBe("gate-col"); expect(enterCalls).toBe(1); // gate-col onEnter expect(exitCalls).toBe(1); // intake-col onExit // Marker cleared (no strand). expect(readTransitionPending(store, task.id)).toBeNull(); }); it("engine-sourced (bypassGuards) moves skip plugin hooks (KTD-9)", async () => { const ir = { version: "v2", name: "CoHooks2", columns: [ { id: "intake-col", name: "Intake", traits: [{ trait: "intake" }] }, { id: "gate-col", name: "Gate", traits: [{ trait: enterTrait }] }, { id: "done-col", name: "Done", traits: [{ trait: "complete" }] }, ], nodes: [ { id: "start", kind: "start", column: "intake-col" }, { id: "end", kind: "end", column: "done-col" }, ], edges: [{ from: "start", to: "end" }], } as WorkflowIr; const def = await store.createWorkflowDefinition({ name: "CoHooks2", ir }); const task = await store.createTask({ description: "card" }); setSelection(store, task.id, def.id); setColumn(store, task.id, "intake-col"); await store.moveTask(task.id, "gate-col", { moveSource: "engine", bypassGuards: true }); expect(enterCalls).toBe(0); // engine move bypasses trait effects }); }); describe("U8 plugin loader aggregation + disable/force-disable (KTD-7)", () => { let rootDir = ""; let pluginStore: PluginStore; let loader: PluginLoader; let taskRoot = ""; let store: TaskStore; const traitContribution: PluginTraitContribution = { traitId: "approval", name: "Approval gate", schemaVersion: 1, flags: { gate: true }, hooks: { gate: { mode: "prompt", prompt: "Approve?", gateMode: "blocking" } }, }; const traitRegistryId = pluginTraitRegistryId("gate-plugin", "approval"); beforeEach(async () => { freshRegistry(); rootDir = mkdtempSync(join(tmpdir(), "u8-loader-")); pluginStore = new PluginStore(rootDir, { inMemoryDb: true, centralGlobalDir: rootDir }); loader = new PluginLoader({ pluginStore, taskStore: { logActivity: vi.fn() } as never }); await pluginStore.init(); taskRoot = mkdtempSync(join(tmpdir(), "u8-loader-tasks-")); git(taskRoot, "init -b main"); git(taskRoot, "config user.name 'Fusion'"); git(taskRoot, "config user.email 'hi@runfusion.ai'"); writeFileSync(join(taskRoot, "README.md"), "root\n"); git(taskRoot, "add README.md"); git(taskRoot, "commit -m init"); store = new TaskStore(taskRoot, undefined, { inMemoryDb: false }); await store.init(); await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); }); afterEach(async () => { try { store?.close(); } catch { /* ignore */ } if (taskRoot) rmSync(taskRoot, { recursive: true, force: true }); const { rm } = await import("node:fs/promises"); await rm(rootDir, { recursive: true, force: true }); __resetTraitRegistryForTests(); }); async function loadGatePlugin(): Promise { const pluginDir = join(rootDir, "plugins"); await mkdir(pluginDir, { recursive: true }); const plugin = { manifest: { id: "gate-plugin", name: "Gate Plugin", version: "1.0.0" }, state: "installed", hooks: {}, traits: [traitContribution], }; const path = join(pluginDir, "gate-plugin.mjs"); await writeFile(path, `const plugin = ${JSON.stringify(plugin, null, 2)}; export default plugin;`); await pluginStore.registerPlugin({ manifest: plugin.manifest, path }); await loader.loadAllPlugins(); } it("loader aggregates plugin trait contributions with ownership", async () => { await loadGatePlugin(); const traits = loader.getPluginTraits(); expect(traits).toHaveLength(1); expect(traits[0].pluginId).toBe("gate-plugin"); expect(traits[0].trait.traitId).toBe("approval"); }); it("disable with cards in a plugin-trait column is BLOCKED with a typed dependents error", async () => { await loadGatePlugin(); const registry = getTraitRegistry(); registerPluginTraits({ registry, pluginId: "gate-plugin", contributions: loader.getPluginTraits().map((t) => t.trait), runCustomNode: PASS_RUNNER, }); // Seed a live card in a column using the plugin trait. const def = await store.createWorkflowDefinition({ name: "Gate WF", ir: customWorkflowIr(traitRegistryId) }); const task = await store.createTask({ description: "card" }); setSelection(store, task.id, def.id); setColumn(store, task.id, "gate-col"); const resolveIr = (taskId: string): WorkflowIr | undefined => store.getTaskWorkflowSelection(taskId)?.workflowId === def.id ? def.ir : undefined; const dependents = await findLivePluginTraitDependents({ store, resolveTaskWorkflowIr: resolveIr, pluginTraitIds: [traitRegistryId], }); expect(dependents).toHaveLength(1); expect(dependents[0].taskId).toBe(task.id); expect(dependents[0].column).toBe("gate-col"); // The typed error is the disable block (mirrors the built-in-workflow block). const err = new PluginTraitHasDependentsError("gate-plugin", dependents); expect(err.dependents).toHaveLength(1); expect(err.message).toContain("gate-plugin"); }); it("force-disable degrades the column to passive: hooks become no-ops, cards still movable", async () => { await loadGatePlugin(); const registry = getTraitRegistry(); registerPluginTraits({ registry, pluginId: "gate-plugin", contributions: loader.getPluginTraits().map((t) => t.trait), runCustomNode: FAIL_RUNNER, // would block if still live }); const def = await store.createWorkflowDefinition({ name: "Gate WF", ir: customWorkflowIr(traitRegistryId, { traitConfig: { gateMode: "blocking" } }) }); const task = await store.createTask({ description: "card" }); setSelection(store, task.id, def.id); setColumn(store, task.id, "intake-col"); // Before degrade: the gate hook impl is registered (not a missing-impl no-op). expect(registry.resolveTraitHook(traitRegistryId, "gate").warning).toBeUndefined(); // Force-disable: degrade the trait's hooks to no-ops. const degraded = degradePluginTraits(registry, [traitRegistryId]); expect(degraded).toContain(traitRegistryId); // The trait definition still resolves (column not bricked) but the hook is // now the degraded no-op + audit warning path. expect(registry.getTrait(traitRegistryId)).toBeDefined(); const resolved = registry.resolveTraitHook(traitRegistryId, "gate"); expect(resolved.warning?.kind).toBe("missing-hook-impl"); // Card is still movable into the degraded column with NO recorded verdict: // the store guard sees the degraded (warning) gate and treats it as passive // (KTD-7 — cards remain movable). A live (non-degraded) blocking gate would // have rejected this move for lack of a verdict. const moved = await store.moveTask(task.id, "gate-col", { moveSource: "user" }); expect(moved.column).toBe("gate-col"); }); });