feat(engine): plugin-contributed traits — async-only hooks, pre-evaluated gates, live-dependent disable guard (U8)

This commit is contained in:
gsxdsm
2026-06-04 01:43:26 -07:00
parent 66cffe904d
commit 26718a31cc
12 changed files with 1708 additions and 1 deletions

View File

@@ -0,0 +1,558 @@
// @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("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");
});
});

View File

@@ -463,6 +463,17 @@ export { HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext } from ".
export { TokenCapDetector, type TokenCapCheckResult } from "./token-cap-detector.js";
export { SelfHealingManager, type SelfHealingOptions, type RebindResult } from "./self-healing.js";
export { PluginRunner, type PluginRunnerOptions } from "./plugin-runner.js";
export {
registerPluginTraits,
degradePluginTraits,
unregisterPluginTraits,
findLivePluginTraitDependents,
pluginTraitToDefinition,
pluginTraitRegistryId,
evaluatePluginGate,
PluginTraitHasDependentsError,
type PluginTraitDependent,
} from "./plugin-trait-adapter.js";
// Agent runtime abstraction
export { type AgentRuntime, type AgentRuntimeOptions, type AgentSessionResult } from "./agent-runtime.js";
export {
@@ -531,6 +542,16 @@ export {
} from "./remote-access/index.js";
export { RemoteNodeClient } from "./runtimes/remote-node-client.js";
export { RemoteNodeRuntime, type RemoteNodeRuntimeConfig } from "./runtimes/remote-node-runtime.js";
// Hold/release sweep + manual promote (U6/U9). Exported so the dashboard
// promote endpoint can release a manually-held card via the same authority.
export {
promoteHeldTask,
releaseHeldTaskByEvent,
runHoldReleaseSweep,
type HoldReleaseDeps,
type HoldReleaseResult,
type SlotReservation,
} from "./hold-release.js";
export { StepSessionExecutor } from "./step-session-executor.js";
export type { StepResult, ParallelWave, StepSessionExecutorOptions } from "./step-session-executor.js";
// Multi-project runtime types

View File

@@ -21,6 +21,9 @@ import type {
PluginContext,
PluginSkillContribution,
PluginWorkflowStepContribution,
PluginTraitContribution,
WorkflowIr,
TaskDetail,
PluginPromptContribution,
PluginPromptContributions,
PluginPromptSurface,
@@ -32,7 +35,24 @@ import type {
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { Type } from "@earendil-works/pi-ai";
import { isAbsolute } from "node:path";
import {
getTraitRegistry,
parseWorkflowIr,
BUILTIN_CODING_WORKFLOW_IR,
getBuiltinWorkflow,
isBuiltinWorkflowId,
} from "@fusion/core";
import { createLogger, executorLog } from "./logger.js";
import type { WorkflowCustomNodeRunner } from "./workflow-node-handlers.js";
import {
registerPluginTraits,
degradePluginTraits,
unregisterPluginTraits,
findLivePluginTraitDependents,
pluginTraitRegistryId,
PluginTraitHasDependentsError,
type PluginTraitDependent,
} from "./plugin-trait-adapter.js";
// Type for the task store's event data
interface TaskMovedEvent {
@@ -106,6 +126,11 @@ interface CachedWorkflowStepTemplates {
version: number;
}
interface CachedTraits {
traits: Array<{ pluginId: string; trait: PluginTraitContribution }>;
version: number;
}
interface CachedPromptContributions {
contributions: Array<{
pluginId: string;
@@ -133,6 +158,7 @@ export class PluginRunner {
private cachedSkills: CachedSkills | null = null;
private cachedWorkflowSteps: CachedWorkflowSteps | null = null;
private cachedWorkflowStepTemplates: CachedWorkflowStepTemplates | null = null;
private cachedTraits: CachedTraits | null = null;
private cachedPromptContributions: CachedPromptContributions | null = null;
private cachedSetupInfo: CachedSetupInfo | null = null;
private toolsCacheVersion = 0;
@@ -144,7 +170,13 @@ export class PluginRunner {
private skillsCacheVersion = 0;
private workflowStepsCacheVersion = 0;
private workflowStepTemplatesCacheVersion = 0;
private traitsCacheVersion = 0;
private promptContributionsCacheVersion = 0;
/** Map of pluginId → the registry trait ids it currently has registered. */
private registeredPluginTraitIds = new Map<string, string[]>();
/** The custom-node runner used to execute plugin trait hooks (set via
* setTraitHookRunner; mirrors how the executor wires runGraphCustomNode). */
private traitHookRunner: WorkflowCustomNodeRunner | undefined;
private setupCacheVersion = 0;
private hookTimeoutMs: number;
@@ -221,6 +253,7 @@ export class PluginRunner {
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
this.invalidateSetupCache();
}
@@ -359,6 +392,183 @@ export class PluginRunner {
return this.cachedWorkflowSteps.steps;
}
/**
* Get all plugin trait contributions with their plugin ids (U8). Aggregated /
* cached / invalidated exactly like workflow steps.
*/
getPluginTraits(): Array<{ pluginId: string; trait: PluginTraitContribution }> {
if (!this.cachedTraits || this.cachedTraits.version !== this.traitsCacheVersion) {
// Older loaders (and some test fakes) predate the traits API — degrade to
// an empty contribution set rather than crashing the runner.
const getter = this.options.pluginLoader.getPluginTraits;
this.cachedTraits = {
traits: typeof getter === "function" ? getter.call(this.options.pluginLoader) : [],
version: this.traitsCacheVersion,
};
}
return this.cachedTraits.traits;
}
/**
* Wire the custom-node runner that executes plugin trait hooks (gate / onEnter
* / onExit / releaseCondition) through the prompt-session/script machinery.
* The executor sets this the way it wires its own runGraphCustomNode. Must be
* set before traits are synced for hooks to actually run (otherwise the
* registry resolves declared hooks to the degraded no-op + audit path).
*/
setTraitHookRunner(runner: WorkflowCustomNodeRunner): void {
this.traitHookRunner = runner;
// Re-sync so already-loaded plugin traits pick up the runner.
this.syncPluginTraits();
}
/**
* Register all currently-loaded plugins' trait contributions into the core
* TraitRegistry (plugin-namespaced ids). Re-runs on cache invalidation. Traits
* for plugins no longer present are dropped from the registry (degraded path
* is the force-disable route; a clean unload removes them).
*/
syncPluginTraits(): void {
const registry = getTraitRegistry();
const runner = this.traitHookRunner;
const current = this.getPluginTraits();
// Group contributions by plugin id.
const byPlugin = new Map<string, PluginTraitContribution[]>();
for (const { pluginId, trait } of current) {
const list = byPlugin.get(pluginId) ?? [];
list.push(trait);
byPlugin.set(pluginId, list);
}
// Drop traits for plugins no longer present.
for (const [pluginId, ids] of [...this.registeredPluginTraitIds.entries()]) {
if (!byPlugin.has(pluginId)) {
unregisterPluginTraits(registry, ids);
this.registeredPluginTraitIds.delete(pluginId);
}
}
if (!runner) {
// No runner yet: don't register hooks (they'd degrade to no-ops anyway).
// Definitions still register so the catalog/validation see them.
for (const [pluginId, contributions] of byPlugin) {
const ids = registerPluginTraits({
registry,
pluginId,
contributions,
runCustomNode: async () => ({ outcome: "success" as const }),
});
this.registeredPluginTraitIds.set(pluginId, ids);
}
return;
}
for (const [pluginId, contributions] of byPlugin) {
try {
const ids = registerPluginTraits({ registry, pluginId, contributions, runCustomNode: runner });
this.registeredPluginTraitIds.set(pluginId, ids);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.log.warn(`Failed to register traits for plugin '${pluginId}': ${msg}`);
}
}
}
/**
* The live-dependents guard (KTD-7). Returns the tasks currently sitting in a
* column that uses one of the plugin's traits. A non-force disable/unregister
* with a non-empty result must be blocked; the force path degrades instead.
*/
async findPluginTraitDependents(pluginId: string): Promise<PluginTraitDependent[]> {
const ids = this.collectPluginTraitRegistryIds(pluginId);
if (ids.length === 0) return [];
return findLivePluginTraitDependents({
store: this.options.taskStore,
resolveTaskWorkflowIr: (taskId) => this.resolveTaskWorkflowIr(taskId),
pluginTraitIds: ids,
});
}
/**
* Disable a plugin's traits. With live dependents and `force !== true`, throws
* `PluginTraitHasDependentsError`. With `force`, degrades the columns to
* passive (hooks become no-ops + audit warning) and emits one audit event;
* cards remain movable.
*/
async disablePluginTraits(pluginId: string, opts?: { force?: boolean }): Promise<{
degraded: string[];
dependents: PluginTraitDependent[];
}> {
const registry = getTraitRegistry();
const ids = this.collectPluginTraitRegistryIds(pluginId);
const dependents = await this.findPluginTraitDependents(pluginId);
if (dependents.length > 0 && !opts?.force) {
throw new PluginTraitHasDependentsError(pluginId, dependents);
}
const degraded = degradePluginTraits(registry, ids);
if (degraded.length > 0) {
try {
this.options.taskStore.recordRunAuditEvent({
agentId: "system",
runId: `plugin-trait-degrade-${pluginId}-${Date.now()}`,
domain: "database",
mutationType: "plugin:trait-degraded",
target: pluginId,
metadata: {
pluginId,
degradedTraitIds: degraded,
affectedTasks: dependents.map((d) => d.taskId),
note: "hooks now resolve to no-ops; cards remain movable",
},
});
} catch {
// Audit is best-effort; degradation already applied.
}
}
return { degraded, dependents };
}
/** Collect the registry trait ids for a plugin (from the registration map, or
* derived from current contributions as a fallback). */
private collectPluginTraitRegistryIds(pluginId: string): string[] {
const tracked = this.registeredPluginTraitIds.get(pluginId);
if (tracked && tracked.length > 0) return tracked;
return this.getPluginTraits()
.filter((t) => t.pluginId === pluginId)
.map((t) => pluginTraitRegistryId(pluginId, t.trait.traitId));
}
/**
* Resolve a task's workflow IR through the public store API (selection +
* workflow definition). Mirrors the store's private resolver but stays on the
* public surface so the adapter never reaches into store internals. Falls back
* to the built-in default workflow on any miss.
*/
private resolveTaskWorkflowIr(taskId: string): WorkflowIr | undefined {
const store = this.options.taskStore;
let workflowId: string | undefined;
try {
workflowId = store.getTaskWorkflowSelection?.(taskId)?.workflowId;
} catch {
return BUILTIN_CODING_WORKFLOW_IR;
}
if (!workflowId) return BUILTIN_CODING_WORKFLOW_IR;
if (isBuiltinWorkflowId(workflowId)) {
return getBuiltinWorkflow(workflowId)?.ir ?? BUILTIN_CODING_WORKFLOW_IR;
}
try {
const db = store.getDatabase();
const row = db.prepare("SELECT ir FROM workflows WHERE id = ?").get(workflowId) as
| { ir: string }
| undefined;
if (!row) return BUILTIN_CODING_WORKFLOW_IR;
return parseWorkflowIr(row.ir);
} catch {
return BUILTIN_CODING_WORKFLOW_IR;
}
}
getPluginWorkflowStepTemplates(): Array<{ pluginId: string; template: WorkflowStepTemplate }> {
if (!this.cachedWorkflowStepTemplates || this.cachedWorkflowStepTemplates.version !== this.workflowStepTemplatesCacheVersion) {
this.cachedWorkflowStepTemplates = {
@@ -572,6 +782,7 @@ export class PluginRunner {
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
this.invalidateSetupCache();
executorLog.log(`Plugin ${pluginId} reloaded`);
@@ -593,6 +804,7 @@ export class PluginRunner {
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
this.invalidateSetupCache();
@@ -619,6 +831,7 @@ export class PluginRunner {
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
this.invalidateSetupCache();
@@ -645,6 +858,7 @@ export class PluginRunner {
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
this.invalidateSetupCache();
@@ -670,6 +884,7 @@ export class PluginRunner {
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
this.invalidateSetupCache();
}
@@ -687,6 +902,7 @@ export class PluginRunner {
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
this.invalidateSetupCache();
}
@@ -704,6 +920,7 @@ export class PluginRunner {
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
this.invalidateSetupCache();
}
@@ -721,6 +938,7 @@ export class PluginRunner {
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
this.invalidateSetupCache();
}
@@ -738,6 +956,7 @@ export class PluginRunner {
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
this.invalidateSetupCache();
}
@@ -970,6 +1189,14 @@ export class PluginRunner {
this.log.log(`Workflow step templates cache invalidated (version: ${this.workflowStepTemplatesCacheVersion})`);
}
private invalidateTraitsCache(): void {
this.traitsCacheVersion++;
this.log.log(`Plugin traits cache invalidated (version: ${this.traitsCacheVersion})`);
// Re-register/deregister plugin traits in the core registry to match the
// newly-loaded/unloaded set (mirrors the workflow-step contribution flow).
this.syncPluginTraits();
}
private invalidatePromptContributionsCache(): void {
this.promptContributionsCacheVersion++;
this.log.log(`Prompt contributions cache invalidated (version: ${this.promptContributionsCacheVersion})`);

View File

@@ -0,0 +1,275 @@
/**
* Plugin trait adapter (U8, R6/R15/R22, KTD-7).
*
* Bridges plugin-contributed traits (`PluginTraitContribution`) into core's
* `TraitRegistry` and routes their executable hooks through the SAME
* prompt-session / script / verdict machinery contributed workflow STEPS use.
*
* Design (mirrors the workflow-step contribution pattern):
* - Plugin trait ids are namespaced `plugin:<pluginId>:<traitId>` so they can
* never collide with built-ins or be overridden (TraitRegistry rejects
* builtin-namespace overrides + restricted flags already).
* - Hooks are async-only (gate/onEnter/onExit/releaseCondition). A sync
* `guard` key is rejected at contribution validation (core), so it never
* reaches the registry.
* - Executable hooks do NOT run raw in-process code. The adapter builds a
* synthetic `WorkflowIrNode` from the hook descriptor (mode + prompt /
* scriptName + gateMode) and delegates to the injected
* `WorkflowCustomNodeRunner` — the exact path contributed workflow steps
* execute through. Gates additionally reuse `createGateHandler` semantics
* (blocking fails closed; advisory records-and-allows).
* - Gates are evaluated PRE-MOVE, outside the task lock (KTD-2): the verdict
* is recorded into the store via `recordPluginGateVerdict`; the store's
* in-lock guard re-checks it cheaply. No plugin code runs in-lock.
*
* Disable/uninstall protection (KTD-7):
* - `findLivePluginTraitDependents` resolves every live task's workflow +
* current column and reports tasks sitting in a column that uses one of the
* plugin's traits. A non-force disable with dependents is blocked.
* - `degradePluginTraits` (force path) deregisters the hook impls so the
* registry resolves them to the no-op + audit-warning path — columns become
* passive, cards stay movable, one audit event is emitted.
*/
import type {
PluginTraitContribution,
PluginTraitHookDescriptor,
TaskStore,
TaskDetail,
TraitDefinition,
TraitHookKind,
WorkflowIr,
WorkflowIrNode,
} from "@fusion/core";
import { TraitRegistry, findWorkflowColumn } from "@fusion/core";
import { createGateHandler } from "./workflow-node-handlers.js";
import type { WorkflowCustomNodeRunner } from "./workflow-node-handlers.js";
import type { WorkflowNodeResult } from "./workflow-graph-executor.js";
/** Build the registry-facing id for a plugin trait. */
export function pluginTraitRegistryId(pluginId: string, traitId: string): string {
return `plugin:${pluginId}:${traitId}`;
}
/** The async hook points a plugin trait may carry. */
const PLUGIN_HOOK_KINDS: readonly Exclude<TraitHookKind, "guard">[] = [
"gate",
"onEnter",
"onExit",
"releaseCondition",
];
/**
* Convert a `PluginTraitContribution` into a core `TraitDefinition`. The result
* is NOT built-in (`builtin` stays falsy), so the registry enforces R22
* (restricted flags / sync guard rejected) on registration as a backstop even
* though core's `validatePluginTraitContribution` already rejected them.
*/
export function pluginTraitToDefinition(
pluginId: string,
contribution: PluginTraitContribution,
): TraitDefinition {
const hooks: TraitDefinition["hooks"] = {};
if (contribution.hooks?.gate) hooks.gate = true;
if (contribution.hooks?.onEnter) hooks.onEnter = true;
if (contribution.hooks?.onExit) hooks.onExit = true;
if (contribution.hooks?.releaseCondition) hooks.releaseCondition = true;
return {
id: pluginTraitRegistryId(pluginId, contribution.traitId),
name: contribution.name,
description: contribution.description,
flags: { ...(contribution.flags ?? {}) },
configSchema: contribution.configSchema
? { fields: contribution.configSchema.fields.map((f) => ({ ...f })) }
: undefined,
hooks: Object.keys(hooks).length > 0 ? hooks : undefined,
builtin: false,
};
}
/**
* Build a synthetic workflow node from a hook descriptor so the hook executes
* through the existing custom-node runner (the contributed-workflow-step path).
*/
function hookDescriptorToNode(
traitRegistryId: string,
hookKind: Exclude<TraitHookKind, "guard">,
descriptor: PluginTraitHookDescriptor,
): WorkflowIrNode {
const isGate = hookKind === "gate";
// The custom-node runner reads `config.gateMode === "gate"` (blocking) vs
// anything else (advisory). Map our blocking/advisory onto that contract.
const gateModeForRunner = descriptor.gateMode === "advisory" ? "advisory" : "gate";
return {
id: `trait:${traitRegistryId}:${hookKind}`,
kind: isGate ? "gate" : "prompt",
config: {
name: traitRegistryId,
prompt: descriptor.prompt ?? "",
scriptName: descriptor.scriptName,
gateMode: isGate ? gateModeForRunner : undefined,
},
} as WorkflowIrNode;
}
/**
* Evaluate a plugin gate descriptor through the gate handler + custom-node
* runner (the same machinery contributed steps use). Returns the node result;
* blocking gates fail closed (a failure outcome → not allowed), advisory gates
* always pass at the handler level (the verdict is still recorded).
*/
export async function evaluatePluginGate(params: {
traitRegistryId: string;
descriptor: PluginTraitHookDescriptor;
task: TaskDetail;
context?: Record<string, unknown>;
runCustomNode: WorkflowCustomNodeRunner;
}): Promise<WorkflowNodeResult> {
const { traitRegistryId, descriptor, task, context, runCustomNode } = params;
const node = hookDescriptorToNode(traitRegistryId, "gate", descriptor);
const handler = createGateHandler(runCustomNode);
return handler(node, { task, context: context ?? {}, settings: undefined });
}
/**
* Register a plugin's trait contributions into the registry and wire each async
* hook's implementation. Hook impls delegate to the injected custom-node runner
* (gate/onEnter/onExit/releaseCondition). Returns the registry ids registered so
* the caller can later degrade/unregister them.
*
* Idempotent per id: a trait already present (same plugin reload) is skipped for
* the definition but its hook impls are refreshed.
*/
export function registerPluginTraits(params: {
registry: TraitRegistry;
pluginId: string;
contributions: PluginTraitContribution[];
/** Resolves the custom-node runner for a given task (the executor's). */
runCustomNode: WorkflowCustomNodeRunner;
}): string[] {
const { registry, pluginId, contributions, runCustomNode } = params;
const registered: string[] = [];
for (const contribution of contributions) {
const def = pluginTraitToDefinition(pluginId, contribution);
if (!registry.has(def.id)) {
// Registration enforces R22 as a backstop (restricted flag / guard hook).
registry.register(def);
}
registered.push(def.id);
for (const hookKind of PLUGIN_HOOK_KINDS) {
const descriptor = contribution.hooks?.[hookKind];
if (!descriptor) continue;
registry.registerTraitHookImpl(def.id, hookKind, ((...args: unknown[]) => {
const ctx = args[0] as
| { task?: TaskDetail; context?: Record<string, unknown> }
| undefined;
const task = ctx?.task;
if (!task) return undefined;
const node = hookDescriptorToNode(def.id, hookKind, descriptor);
return runCustomNode(node, task, ctx?.context ?? {});
}) as (...args: unknown[]) => unknown);
}
}
return registered;
}
/** A live task sitting in a column that uses one of a plugin's traits. */
export interface PluginTraitDependent {
taskId: string;
column: string;
/** The registry ids of the plugin's traits used by that column. */
traitIds: string[];
}
/** Typed error for a blocked disable/unregister with live dependents (KTD-7). */
export class PluginTraitHasDependentsError extends Error {
readonly pluginId: string;
readonly dependents: PluginTraitDependent[];
constructor(pluginId: string, dependents: PluginTraitDependent[]) {
super(
`Cannot disable plugin '${pluginId}': ${dependents.length} task(s) are in columns using its traits ` +
`(${dependents.map((d) => `${d.taskId}@${d.column}`).join(", ")}). ` +
`Force-disable to degrade those columns to passive.`,
);
this.name = "PluginTraitHasDependentsError";
this.pluginId = pluginId;
this.dependents = dependents;
}
}
/**
* Resolve every live (non-archived) task's workflow + current column and report
* those sitting in a column that uses one of the given plugin trait registry
* ids. Pure read-side: resolves the workflow IR through the injected resolver
* (so we don't reach into the store's private methods).
*/
export async function findLivePluginTraitDependents(params: {
store: Pick<TaskStore, "listTasks">;
/** Resolve the (already-parsed) workflow IR for a task id. */
resolveTaskWorkflowIr: (taskId: string) => WorkflowIr | undefined;
/** The registry ids of the plugin's traits to check for. */
pluginTraitIds: string[];
}): Promise<PluginTraitDependent[]> {
const { store, resolveTaskWorkflowIr, pluginTraitIds } = params;
const traitSet = new Set(pluginTraitIds);
if (traitSet.size === 0) return [];
const dependents: PluginTraitDependent[] = [];
const tasks = await store.listTasks({ slim: true, includeArchived: false });
for (const task of tasks) {
const ir = resolveTaskWorkflowIr(task.id);
if (!ir) continue;
const column = findWorkflowColumn(ir, task.column);
if (!column) continue;
const used = column.traits
.map((ct) => ct.trait)
.filter((id) => traitSet.has(id));
if (used.length > 0) {
dependents.push({ taskId: task.id, column: task.column, traitIds: used });
}
}
return dependents;
}
/**
* Degrade a plugin's traits to passive (force-disable path, KTD-7). Deregisters
* the hook impls so the registry resolves them to the no-op + audit-warning
* path; the trait definitions stay registered so columns referencing them keep
* resolving (cards remain movable). Returns the list of degraded registry ids.
*/
export function degradePluginTraits(
registry: TraitRegistry,
pluginTraitIds: string[],
): string[] {
const degraded: string[] = [];
for (const id of pluginTraitIds) {
const def = registry.getTrait(id);
if (!def) continue;
let any = false;
for (const hookKind of PLUGIN_HOOK_KINDS) {
if (registry.deregisterTraitHookImpl(id, hookKind)) any = true;
}
if (any || def.hooks) degraded.push(id);
}
return degraded;
}
/**
* Fully unregister a plugin's traits from the registry (no live dependents).
* Removes the definitions and any hook impls. Returns removed registry ids.
*/
export function unregisterPluginTraits(
registry: TraitRegistry,
pluginTraitIds: string[],
): string[] {
const removed: string[] = [];
for (const id of pluginTraitIds) {
if (registry.unregisterTrait(id)) removed.push(id);
}
return removed;
}