649 lines
25 KiB
TypeScript
649 lines
25 KiB
TypeScript
// @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<string, unknown> }): 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<WorkflowNodeResult> => ({
|
|
outcome: "success",
|
|
value: "passed",
|
|
});
|
|
const FAIL_RUNNER: WorkflowCustomNodeRunner = async (): Promise<WorkflowNodeResult> => ({
|
|
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<string, unknown>): Promise<string> {
|
|
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<void> {
|
|
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");
|
|
});
|
|
});
|