feat(engine): plugin-contributed traits — async-only hooks, pre-evaluated gates, live-dependent disable guard (U8)
This commit is contained in:
558
packages/engine/src/__tests__/plugin-traits.test.ts
Normal file
558
packages/engine/src/__tests__/plugin-traits.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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})`);
|
||||
|
||||
275
packages/engine/src/plugin-trait-adapter.ts
Normal file
275
packages/engine/src/plugin-trait-adapter.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user