From 26718a31cc4ed5ab69d7cbdaa4f88db50d504155 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 01:43:26 -0700 Subject: [PATCH] =?UTF-8?q?feat(engine):=20plugin-contributed=20traits=20?= =?UTF-8?q?=E2=80=94=20async-only=20hooks,=20pre-evaluated=20gates,=20live?= =?UTF-8?q?-dependent=20disable=20guard=20(U8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/PLUGIN_AUTHORING.md | 129 ++++ packages/core/src/index.ts | 23 +- packages/core/src/plugin-gate-verdict.ts | 83 +++ packages/core/src/plugin-loader.ts | 16 + packages/core/src/plugin-types.ts | 242 ++++++++ packages/core/src/store.ts | 96 +++ packages/core/src/trait-registry.ts | 27 + .../src/__tests__/plugin-traits.test.ts | 558 ++++++++++++++++++ packages/engine/src/index.ts | 21 + packages/engine/src/plugin-runner.ts | 227 +++++++ packages/engine/src/plugin-trait-adapter.ts | 275 +++++++++ packages/plugin-sdk/src/index.ts | 12 + 12 files changed, 1708 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/plugin-gate-verdict.ts create mode 100644 packages/engine/src/__tests__/plugin-traits.test.ts create mode 100644 packages/engine/src/plugin-trait-adapter.ts diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 288ee955b5..2c25c2b751 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -20,6 +20,7 @@ A comprehensive guide to creating Fusion plugins that extend the task board with 14. [Example Plugins](#14-example-plugins) 15. [Registering Skills](#15-registering-skills) 16. [Registering Workflow Steps](#16-registering-workflow-steps) +16.5. [Contributing Column Traits](#165-contributing-column-traits) 17. [Contributing Prompt Modifications](#17-contributing-prompt-modifications) 18. [Plugin Binary Setup Hooks](#18-plugin-binary-setup-hooks) @@ -1476,6 +1477,134 @@ Use `mode: "prompt" | "script"` and `toolMode: "readonly" | "coding"`. Plugin-contributed workflow steps are materialized through core `resolvePluginWorkflowStep(...)`; `mode`, `phase`, `scriptName`, `toolMode`, `defaultOn`, `modelProvider`, and `modelId` are preserved from your contribution (with defaults when omitted). +## 16.5. Contributing Column Traits + +> Requires the `experimentalFeatures.workflowColumns` flag. Traits are the +> composable building blocks of workflow-defined columns (declarative flags + +> lifecycle hooks). Plugins contribute traits the same way they contribute +> workflow steps: declare them on the plugin object and the engine aggregates, +> caches, and invalidates them through the `PluginRunner` (mirroring +> `workflowSteps`). + +A plugin trait is registered into the core trait registry under a +plugin-namespaced id `plugin::`, so it can never collide +with a built-in trait or another plugin's trait, and it resolves through the +same registry lookup as the 14 built-in traits. + +```typescript +import type { PluginTraitContribution } from "@fusion/plugin-sdk"; + +const traits: PluginTraitContribution[] = [ + { + traitId: "security-approval", + name: "Security Approval Gate", + description: "Holds a card until a security review prompt passes.", + schemaVersion: 1, + flags: { gate: true }, + hooks: { + gate: { + mode: "prompt", + prompt: "Approve this change for security-sensitive paths?", + gateMode: "blocking", + }, + }, + }, + { + traitId: "slack-notify", + name: "Slack Notify", + description: "Posts to Slack when a card enters/leaves the column.", + schemaVersion: 1, + flags: { notify: true }, + hooks: { + onEnter: { mode: "script", scriptName: "slack-notify-enter" }, + onExit: { mode: "script", scriptName: "slack-notify-exit" }, + }, + }, +]; + +export default definePlugin({ + manifest: { id: "my-plugin", name: "My Plugin", version: "1.0.0" }, + hooks: {}, + traits, +}); +``` + +### Contribution shape + +| Field | Required | Notes | +|---|---|---| +| `traitId` | yes | kebab-case slug, unique within the plugin | +| `name` | yes | display name | +| `description` | no | UI description | +| `schemaVersion` | yes | must be `1` — the versioned hook-descriptor contract (see below) | +| `flags` | no | declarative flags (restricted flags rejected, see below) | +| `configSchema` | no | declarative config fields (`{ fields: [...] }`) | +| `hooks` | no | async hook descriptors (see below) | + +### Hook points (async only) + +Plugin traits get **async hook points only**: + +- `gate` — evaluated **before** a card moves into the column (pre-move, outside + the task lock). The verdict is recorded and re-checked cheaply when the move + commits. +- `onEnter` / `onExit` — post-commit, async, idempotent effects. +- `releaseCondition` — evaluated by the hold/release sweep for `hold` columns. + +The synchronous `guard` hook point is **built-in-only** and is rejected at +validation. Sync guards run inside the task lock and must be fast and pure — a +plugin hook there could wedge the lock, so plugins use the async `gate` surface +instead. + +Each hook descriptor mirrors the workflow-step shape: + +```typescript +{ mode: "prompt" | "script", prompt?: string, scriptName?: string, gateMode?: "blocking" | "advisory" } +``` + +Hooks execute through the **same prompt-session / script / verdict machinery** +contributed workflow steps use — plugin trait code never runs raw in-process. + +### Gate semantics + +- `gateMode: "blocking"` (default for gates) **fails closed**: a non-pass + verdict — or no recorded verdict at move time — rejects the move with a typed + `TransitionRejection`. +- `gateMode: "advisory"` **records and allows**: the verdict is logged but the + move proceeds. +- Engine-sourced and recovery moves bypass gates entirely (they carry + `bypassGuards`), so self-healing is never blocked by a plugin gate. + +### Restricted flags + +A plugin trait may **not** declare these flags (rejected at validation, and as a +backstop at registry registration): + +- `complete` — a terminal-success column that silently satisfies dependencies. +- `archived` — globally hidden column semantics. + +A plugin needing those semantics composes its trait **alongside** the built-in +`complete` / `archived` trait on the same column. + +### Versioned hook-descriptor schema + +`schemaVersion: 1` is required. It pins the hook-descriptor contract so the +built-in trait vocabulary can grow additively (new flags, hook points, config +fields) without breaking already-published plugin traits. Validate your +contribution with `validatePluginTraitContribution(...)` from +`@fusion/plugin-sdk`. + +### Disabling a plugin with live dependents + +If a card is currently sitting in a column that uses one of your plugin's +traits, disabling/uninstalling the plugin is **blocked** with a typed error +listing the dependent tasks (mirroring the built-in-workflow deletion block). + +A **force** path degrades the affected columns to **passive**: the trait's hooks +become no-ops (the registry resolves them to a no-op plus an audit warning), a +single audit event is emitted, and the cards remain fully movable. A degraded +gate column never blocks a card. + ## 17. Contributing Prompt Modifications Prompt contributions let a plugin inject additional instructions into specific prompt surfaces. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0a9fe5e003..d2f17d4950 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -102,6 +102,10 @@ export { registerBuiltinTraits, } from "./builtin-traits.js"; export type { BuiltinTraitId } from "./builtin-traits.js"; +export { + registerDefaultWorkflowHooks, + __resetDefaultWorkflowHooksForTests, +} from "./default-workflow-hooks.js"; // ── Typed transition contract + crash-safe marker (U3) ─────────────── export type { TransitionRejection, @@ -132,6 +136,12 @@ export { } from "./workflow-transitions.js"; export type { ColumnAdjacency } from "./workflow-transitions.js"; export { isWorkflowColumnsEnabled } from "./workflow-columns-settings.js"; +// ── U8: pre-evaluated plugin gate verdicts (KTD-2) ─────────────────────────── +export { + findWorkflowColumn, + resolveColumnPluginGates, +} from "./plugin-gate-verdict.js"; +export type { PluginGateVerdict, ColumnPluginGate } from "./plugin-gate-verdict.js"; // ── U6: workflow capacity (WIP) resolution shared by store + sweep ─────────── export { resolveColumnCapacity } from "./workflow-capacity.js"; export type { ColumnCapacity } from "./workflow-capacity.js"; @@ -672,6 +682,9 @@ export type { PluginLogger, PluginSkillContribution, PluginWorkflowStepContribution, + PluginTraitContribution, + PluginTraitHookDescriptor, + PluginTraitFlags, PluginPromptSurface, PluginPromptContribution, PluginPromptContributions, @@ -686,7 +699,15 @@ export type { PluginState, PluginInstallation, } from "./plugin-types.js"; -export { validatePluginManifest, normalizePluginUiContributionSurface, normalizePluginUiContributionDefinition } from "./plugin-types.js"; +export { + validatePluginManifest, + validatePluginTraitContribution, + PLUGIN_TRAIT_RESTRICTED_FLAGS, + PLUGIN_TRAIT_ALLOWED_HOOK_POINTS, + PLUGIN_TRAIT_SCHEMA_VERSION, + normalizePluginUiContributionSurface, + normalizePluginUiContributionDefinition, +} from "./plugin-types.js"; export { PluginStore } from "./plugin-store.js"; export type { PluginStoreEvents, PluginRegistrationInput, PluginUpdateInput } from "./plugin-store.js"; export { PluginLoader } from "./plugin-loader.js"; diff --git a/packages/core/src/plugin-gate-verdict.ts b/packages/core/src/plugin-gate-verdict.ts new file mode 100644 index 0000000000..ad51217afb --- /dev/null +++ b/packages/core/src/plugin-gate-verdict.ts @@ -0,0 +1,83 @@ +/** + * Pre-evaluated plugin gate verdicts (U8, KTD-2). + * + * Per KTD-2 a plugin gate is evaluated *before* the move is attempted, OUTSIDE + * the task lock (via the prompt-session/script/verdict machinery). The verdict + * is recorded and then re-checked cheaply IN-LOCK at move time — this removes + * any path where plugin code can block or wedge the task lock. + * + * The engine (PluginRunner trait adapter) evaluates the gate and records the + * verdict through `TaskStore.recordPluginGateVerdict`; the flag-ON guard site in + * `moveTaskInternal` consumes it through `consumePluginGateVerdicts` and rejects + * the move when a blocking gate has no recorded `allow` verdict. + * + * U8 keeps the storage minimal and surgical (an in-memory map on the store) per + * the unit's "define it here minimally" note. The shape below is the seam a + * later unit can back with SQLite without changing call sites. + */ + +import type { WorkflowIr, WorkflowIrV2, WorkflowIrColumn } from "./workflow-ir-types.js"; +import type { TraitDefinition } from "./trait-types.js"; + +/** A recorded gate verdict for a (task, targetColumn, trait). */ +export interface PluginGateVerdict { + /** The registry-facing trait id (e.g. `plugin::`). */ + traitId: string; + /** Whether the gate verdict allows the move into the target column. */ + allow: boolean; + /** `blocking` fails closed on a non-allow verdict; `advisory` records+allows. */ + gateMode: "blocking" | "advisory"; + /** Human-readable detail surfaced in the rejection / audit. */ + detail?: string; + /** When the verdict was recorded (epoch ms). */ + recordedAt: number; +} + +/** A plugin gate trait found on a column (id + its declared gate mode). */ +export interface ColumnPluginGate { + /** The column trait's registry id. */ + traitId: string; + /** Gate mode from the column trait's `config.gateMode` (defaults to blocking). */ + gateMode: "blocking" | "advisory"; +} + +/** Resolve a workflow column by id from a (v2) IR, or undefined. */ +export function findWorkflowColumn( + ir: WorkflowIr, + columnId: string, +): WorkflowIrColumn | undefined { + const v2 = ir as WorkflowIrV2; + if (!Array.isArray(v2.columns)) return undefined; + return v2.columns.find((c) => c.id === columnId); +} + +/** + * Identify the PLUGIN gate traits on a target column. A trait qualifies when: + * - its registry id is namespaced (`plugin:...`) — built-in gate traits are + * handled by the built-in gate path, not this plugin-facing surface; AND + * - it actually declares a gate (a `gate` hook descriptor or the `gate` flag), + * resolved via `lookupTrait`. A plugin trait with only onEnter/onExit/etc. + * is NOT a gate and must not demand a verdict. + * + * The gate mode is read from the column trait's `config.gateMode` (defaults to + * blocking, matching the built-in gate's fail-closed posture). + */ +export function resolveColumnPluginGates( + column: WorkflowIrColumn | undefined, + lookupTrait?: (traitId: string) => TraitDefinition | undefined, +): ColumnPluginGate[] { + if (!column) return []; + const gates: ColumnPluginGate[] = []; + for (const ct of column.traits) { + if (!ct.trait.startsWith("plugin:")) continue; + const def = lookupTrait?.(ct.trait); + // When a lookup is supplied, require the trait to actually declare a gate. + // Without a lookup (no registry access) we fall back to treating any plugin + // trait as a potential gate — the conservative fail-closed default. + if (lookupTrait && !(def?.hooks?.gate || def?.flags?.gate)) continue; + const cfgMode = ct.config?.gateMode; + const gateMode = cfgMode === "advisory" ? "advisory" : "blocking"; + gates.push({ traitId: ct.trait, gateMode }); + } + return gates; +} diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index 4d06bb6532..e36610bbfa 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -32,6 +32,7 @@ import type { PluginInstallation, PluginSkillContribution, PluginWorkflowStepContribution, + PluginTraitContribution, PluginPromptContribution, PluginPromptContributions, PluginSetupManifest, @@ -1036,6 +1037,21 @@ export class PluginLoader extends EventEmitter<{ return steps; } + /** + * Get all trait contributions from loaded plugins (U8). + */ + getPluginTraits(): Array<{ pluginId: string; trait: PluginTraitContribution }> { + const traits: Array<{ pluginId: string; trait: PluginTraitContribution }> = []; + for (const [pluginId, plugin] of this.plugins) { + if (plugin.traits) { + for (const trait of plugin.traits) { + traits.push({ pluginId, trait }); + } + } + } + return traits; + } + /** * Get all workflow step templates derived from loaded plugin contributions. */ diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts index 2af8d98957..4158bb57b1 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -49,6 +49,8 @@ export interface PluginManifest { skills?: Array<{ skillId: string; name: string }>; /** Optional workflow step metadata used for discovery UIs. */ workflowSteps?: Array<{ stepId: string; name: string }>; + /** Optional trait metadata used for discovery UIs (U8). */ + traits?: Array<{ traitId: string; name: string }>; /** Prompt surfaces this plugin contributes to. */ promptSurfaces?: PluginPromptSurface[]; /** Setup metadata for plugin-managed binaries/runtimes. */ @@ -692,6 +694,212 @@ export interface PluginWorkflowStepContribution { modelId?: string; } +/** + * Plugin-contributed trait (U8, R6/R22, KTD-7). + * + * Plugins declare traits in their manifest the way they declare workflow steps. + * A trait carries declarative flags + an optional config schema + async-only + * hook descriptors. The contract is a VERSIONED hook-descriptor schema + * (`schemaVersion`) so the built-in trait vocabulary can grow additively (new + * flags, hook points, config fields) without breaking published plugin traits. + * + * Restricted (built-in-only) capabilities a plugin trait may NOT declare (R22, + * KTD-2/KTD-7), rejected at validation: + * - the `complete` / `archived` flags (silently satisfying dependencies / + * hiding cards is a scheduling-poison surface); + * - a sync `guard` hook (sync guards run in-lock and must be fast/pure — a + * plugin hook there could wedge the task lock). + * + * Plugin traits get ASYNC hook points only: `gate`, `onEnter`, `onExit`, + * `releaseCondition`. Each hook descriptor mirrors PluginWorkflowStepContribution's + * declarative shape (mode + prompt/scriptName) so the existing prompt-session / + * script / verdict machinery executes them; gates additionally carry `gateMode`. + */ +export interface PluginTraitHookDescriptor { + /** How the hook runs: a model prompt or a named project script. */ + mode: "prompt" | "script"; + /** Prompt text used when `mode === "prompt"`. */ + prompt?: string; + /** Named project script used when `mode === "script"`. */ + scriptName?: string; + /** + * Gate semantics (gate hook only): `blocking` fails closed (a non-pass + * verdict rejects the move); `advisory` records the verdict and allows the + * move. Ignored for non-gate hooks. Defaults to `blocking` for gate hooks. + */ + gateMode?: "blocking" | "advisory"; +} + +/** + * The declarative flag subset a plugin trait may declare. Restricted flags + * (`complete`, `archived`) are intentionally absent from this type AND rejected + * at validation — declaring them is a contribution error, not silently ignored. + */ +export interface PluginTraitFlags { + countsTowardWip?: boolean; + hiddenFromBoard?: boolean; + abortOnExit?: boolean; + humanReview?: boolean; + intake?: boolean; + hold?: boolean; + mergeOrchestration?: boolean; + mergeBlocker?: boolean; + resetOnEntry?: boolean; + timing?: boolean; + stallDetection?: boolean; + notify?: boolean; + gate?: boolean; +} + +export interface PluginTraitContribution { + /** Unique trait identifier within the plugin namespace (kebab-case). The + * registry-facing id is namespaced as `plugin::`. */ + traitId: string; + /** Human-readable trait name. */ + name: string; + /** Short description for UI. */ + description?: string; + /** Versioned hook-descriptor schema. Currently `1`. Required so the + * vocabulary can extend additively without breaking published traits. */ + schemaVersion: 1; + /** Declarative flags (restricted flags rejected at validation, R22). */ + flags?: PluginTraitFlags; + /** Optional declarative config schema fields (shape mirrors TraitConfigField). */ + configSchema?: { + fields: Array<{ + key: string; + type: "string" | "number" | "boolean" | "enum" | "object" | "array"; + required?: boolean; + enumValues?: readonly string[]; + description?: string; + }>; + }; + /** Async-only hook descriptors (R22). A `guard` key is NOT permitted and is + * rejected at validation. */ + hooks?: { + gate?: PluginTraitHookDescriptor; + onEnter?: PluginTraitHookDescriptor; + onExit?: PluginTraitHookDescriptor; + releaseCondition?: PluginTraitHookDescriptor; + }; +} + +/** The restricted flag keys a plugin trait may not declare (R22, KTD-7). */ +export const PLUGIN_TRAIT_RESTRICTED_FLAGS = ["complete", "archived"] as const; + +/** The async-only hook points a plugin trait may declare (R22). The sync + * `guard` hook point is built-in-only and rejected at validation. */ +export const PLUGIN_TRAIT_ALLOWED_HOOK_POINTS = [ + "gate", + "onEnter", + "onExit", + "releaseCondition", +] as const; + +/** The current plugin trait hook-descriptor schema version. */ +export const PLUGIN_TRAIT_SCHEMA_VERSION = 1 as const; + +/** + * Validate one plugin trait contribution. Returns a list of human-readable + * error strings (empty = valid). Mirrors the validation posture of + * `validatePluginManifest`'s `workflowSteps` block: structural checks plus the + * R22 restricted-capability checks (sync `guard` key, restricted flags) and the + * required versioned `schemaVersion`. + */ +export function validatePluginTraitContribution( + trait: unknown, + index = 0, +): string[] { + const errors: string[] = []; + const prefix = `traits[${index}]`; + if (!trait || typeof trait !== "object" || Array.isArray(trait)) { + return [`${prefix} must be an object`]; + } + const t = trait as Record; + + if (!t.traitId || typeof t.traitId !== "string" || t.traitId.trim() === "") { + errors.push(`${prefix}.traitId is required and must be a non-empty string`); + } else if (!SLUG_PATTERN.test(t.traitId)) { + errors.push( + `${prefix}.traitId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)`, + ); + } + + if (!t.name || typeof t.name !== "string" || t.name.trim() === "") { + errors.push(`${prefix}.name is required and must be a non-empty string`); + } + + // schemaVersion is required and must be the supported version (versioned + // hook-descriptor extension contract). + if (t.schemaVersion === undefined) { + errors.push(`${prefix}.schemaVersion is required (versioned hook-descriptor schema)`); + } else if (t.schemaVersion !== PLUGIN_TRAIT_SCHEMA_VERSION) { + errors.push( + `${prefix}.schemaVersion must be ${PLUGIN_TRAIT_SCHEMA_VERSION}; got ${String(t.schemaVersion)}`, + ); + } + + // Restricted flags (R22): a plugin trait must not declare complete/archived. + if (t.flags !== undefined) { + if (typeof t.flags !== "object" || t.flags === null || Array.isArray(t.flags)) { + errors.push(`${prefix}.flags must be an object`); + } else { + const flags = t.flags as Record; + for (const restricted of PLUGIN_TRAIT_RESTRICTED_FLAGS) { + if (flags[restricted]) { + errors.push( + `${prefix}.flags.${restricted} is a restricted (built-in-only) flag and may not be declared by a plugin trait`, + ); + } + } + } + } + + // Hooks: async-only. A sync `guard` key is rejected (R22, KTD-2). + if (t.hooks !== undefined) { + if (typeof t.hooks !== "object" || t.hooks === null || Array.isArray(t.hooks)) { + errors.push(`${prefix}.hooks must be an object`); + } else { + const hooks = t.hooks as Record; + if ("guard" in hooks) { + errors.push( + `${prefix}.hooks.guard is a sync (built-in-only) hook point and may not be declared by a plugin trait`, + ); + } + for (const [hookKind, descriptor] of Object.entries(hooks)) { + if (hookKind === "guard") continue; // already reported + if (!(PLUGIN_TRAIT_ALLOWED_HOOK_POINTS as readonly string[]).includes(hookKind)) { + errors.push( + `${prefix}.hooks.${hookKind} is not a recognized async hook point (allowed: ${PLUGIN_TRAIT_ALLOWED_HOOK_POINTS.join(", ")})`, + ); + continue; + } + if (!descriptor || typeof descriptor !== "object") { + errors.push(`${prefix}.hooks.${hookKind} must be an object`); + continue; + } + const d = descriptor as Record; + if (d.mode !== "prompt" && d.mode !== "script") { + errors.push(`${prefix}.hooks.${hookKind}.mode must be one of: prompt, script`); + } + if (d.mode === "script" && (typeof d.scriptName !== "string" || d.scriptName.trim() === "")) { + errors.push(`${prefix}.hooks.${hookKind}.scriptName is required when mode is "script"`); + } + if ( + hookKind === "gate" && + d.gateMode !== undefined && + d.gateMode !== "blocking" && + d.gateMode !== "advisory" + ) { + errors.push(`${prefix}.hooks.gate.gateMode must be one of: blocking, advisory`); + } + } + } + } + + return errors; +} + /** * Prompt injection surfaces for plugin-contributed instructions. * - executor-system: Appended to executor agent system prompt @@ -829,6 +1037,8 @@ export interface FusionPlugin { skills?: PluginSkillContribution[]; /** Plugin-contributed workflow step templates. */ workflowSteps?: PluginWorkflowStepContribution[]; + /** Plugin-contributed column traits (U8). */ + traits?: PluginTraitContribution[]; /** Plugin-contributed prompt injections. */ promptContributions?: PluginPromptContributions; /** Plugin-managed setup metadata and lifecycle hooks. */ @@ -1024,6 +1234,38 @@ export function validatePluginManifest(manifest: unknown): { valid: boolean; err } } + // Optional: plugin trait contributions (U8). Full contribution shapes (with + // hooks/flags) validate via validatePluginTraitContribution; the discovery + // metadata form (`{ traitId, name }`) validates structurally here. + if (m.traits !== undefined) { + if (!Array.isArray(m.traits)) { + errors.push("traits must be an array"); + } else { + for (const [index, trait] of m.traits.entries()) { + if (!trait || typeof trait !== "object") { + errors.push(`traits[${index}] must be an object`); + continue; + } + const traitMeta = trait as Record; + // A full contribution carries schemaVersion/flags/hooks — validate it + // fully. The discovery-metadata form (just traitId + name) is validated + // structurally. + if (traitMeta.schemaVersion !== undefined || traitMeta.hooks !== undefined || traitMeta.flags !== undefined) { + errors.push(...validatePluginTraitContribution(traitMeta, index)); + continue; + } + if (!traitMeta.traitId || typeof traitMeta.traitId !== "string" || traitMeta.traitId.trim() === "") { + errors.push(`traits[${index}].traitId is required and must be a non-empty string`); + } else if (!SLUG_PATTERN.test(traitMeta.traitId)) { + errors.push(`traits[${index}].traitId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)`); + } + if (!traitMeta.name || typeof traitMeta.name !== "string" || traitMeta.name.trim() === "") { + errors.push(`traits[${index}].name is required and must be a non-empty string`); + } + } + } + } + // Optional: prompt surface metadata if (m.promptSurfaces !== undefined) { if (!Array.isArray(m.promptSurfaces)) { diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 5b9b51b49b..f0d0a8da05 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -10,6 +10,12 @@ import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js"; import { parseWorkflowIr, serializeWorkflowIr } from "./workflow-ir.js"; import { isWorkflowColumnsEnabled } from "./workflow-columns-settings.js"; import { resolveAllowedColumns, workflowHasColumn } from "./workflow-transitions.js"; +import { + type PluginGateVerdict, + findWorkflowColumn, + resolveColumnPluginGates, +} from "./plugin-gate-verdict.js"; +import { getTraitRegistry } from "./trait-registry.js"; import { resolveColumnCapacity } from "./workflow-capacity.js"; import { OccupiedColumnsError, @@ -1209,6 +1215,15 @@ export class TaskStore extends EventEmitter { private watcher: FSWatcher | null = null; /** In-memory cache of tasks for diffing watcher events */ private taskCache: Map = new Map(); + /** + * U8 (KTD-2): pre-evaluated plugin gate verdicts, keyed `taskId` → `toColumn` + * → recorded verdicts (one per plugin gate trait). A plugin gate is evaluated + * OUTSIDE the lock by the engine's trait adapter; the verdict is recorded here + * and re-checked cheaply in-lock at move time so plugin code never blocks or + * wedges the task lock. Kept in-memory (minimal/surgical per U8); the + * `plugin-gate-verdict.ts` seam can later back this with SQLite. + */ + private pluginGateVerdicts: Map> = new Map(); /** Paths recently written by in-process mutations (suppresses duplicate events) */ private recentlyWritten: Set = new Set(); /** Pending debounce timers keyed by task ID */ @@ -5778,6 +5793,48 @@ export class TaskStore extends EventEmitter { `Cannot move ${id} to done: ${guardReason}`, ); } + // 4. Plugin gate verdict re-check (U8, KTD-2). For each PLUGIN gate trait + // on the target column, consume the pre-evaluated verdict (recorded by + // the engine's trait adapter outside the lock). A blocking gate with + // no recorded `allow` verdict fails closed (typed rejection); advisory + // gates record-and-allow. Built-in gates are handled by their own + // path; this guard is the plugin gate surface only. + const registry = getTraitRegistry(); + const pluginGates = resolveColumnPluginGates( + findWorkflowColumn(workflowIr, toColumn), + (tid) => registry.getTrait(tid), + ); + if (pluginGates.length > 0) { + const recorded = this.consumePluginGateVerdicts(id, toColumn); + const byTrait = new Map(recorded.map((v) => [v.traitId, v])); + for (const gate of pluginGates) { + if (gate.gateMode === "advisory") continue; // record-and-allow + // Degraded (force-disabled) plugin gate: its hook impl is gone, so + // the registry resolves it to a no-op + audit warning (KTD-7). A + // degraded gate is PASSIVE — the column never blocks the card; the + // registry's warning is the audit signal. Cards remain movable. + const resolved = registry.resolveTraitHook(gate.traitId, "gate"); + if (resolved.warning) continue; + const verdict = byTrait.get(gate.traitId); + // Fail closed: a blocking gate with no recorded allow verdict rejects. + if (!verdict || !verdict.allow) { + const reason = + verdict?.detail ?? + (verdict + ? `Gate '${gate.traitId}' did not pass` + : `Gate '${gate.traitId}' has not been evaluated for this move`); + throw new TransitionRejectionError( + makeTransitionRejection( + "merge-blocked", + "transition.rejected.gateBlocked", + true, + reason, + ), + `Cannot move ${id} to '${toColumn}': ${reason}`, + ); + } + } + } } } else { // ── Flag-OFF legacy path (unchanged) ─────────────────────────────────── @@ -11903,6 +11960,45 @@ ${stepsSection}`; * missing custom row falls back to the default workflow so a move is never * stranded by a corrupt definition (degraded, not crashed). */ + /** + * U8 (KTD-2): record a pre-evaluated plugin gate verdict for a move into + * `toColumn`. Called by the engine's plugin trait adapter AFTER it evaluated + * the gate (prompt/script) outside the task lock. The flag-ON guard site in + * `moveTaskInternal` re-checks the recorded verdict in-lock. Verdicts are + * consumed (cleared) by `consumePluginGateVerdicts` once read so a stale + * verdict can't silently re-authorize a later move. + */ + recordPluginGateVerdict( + taskId: string, + toColumn: string, + verdict: Omit & { recordedAt?: number }, + ): void { + let byColumn = this.pluginGateVerdicts.get(taskId); + if (!byColumn) { + byColumn = new Map(); + this.pluginGateVerdicts.set(taskId, byColumn); + } + const list = byColumn.get(toColumn) ?? []; + // Replace any prior verdict for the same trait (latest evaluation wins). + const filtered = list.filter((v) => v.traitId !== verdict.traitId); + filtered.push({ ...verdict, recordedAt: verdict.recordedAt ?? Date.now() }); + byColumn.set(toColumn, filtered); + } + + /** + * U8: read AND clear the recorded plugin gate verdicts for a (task, column). + * Returns the recorded verdicts (possibly empty). Consuming clears them so the + * verdict authorizes exactly one move attempt. + */ + consumePluginGateVerdicts(taskId: string, toColumn: string): PluginGateVerdict[] { + const byColumn = this.pluginGateVerdicts.get(taskId); + if (!byColumn) return []; + const list = byColumn.get(toColumn) ?? []; + byColumn.delete(toColumn); + if (byColumn.size === 0) this.pluginGateVerdicts.delete(taskId); + return list; + } + private resolveTaskWorkflowIrSync(taskId: string): WorkflowIr { const selection = this.getTaskWorkflowSelection(taskId); const workflowId = selection?.workflowId; diff --git a/packages/core/src/trait-registry.ts b/packages/core/src/trait-registry.ts index e24f6936e2..b946ee151d 100644 --- a/packages/core/src/trait-registry.ts +++ b/packages/core/src/trait-registry.ts @@ -160,6 +160,33 @@ export class TraitRegistry { this.hookImpls.set(traitHookKey(traitId, hookKind), impl); } + /** + * Deregister a hook implementation for a (traitId, hookKind). After this, a + * trait that still DECLARES the hook resolves to a no-op + audit warning (the + * degraded path) rather than executing — this is exactly the "force-disable a + * plugin → columns degrade to passive" path (U8/KTD-7). Returns true if an + * impl was present and removed. + */ + deregisterTraitHookImpl(traitId: string, hookKind: TraitHookKind): boolean { + return this.hookImpls.delete(traitHookKey(traitId, hookKind)); + } + + /** + * Remove a trait definition entirely (e.g. when a plugin is fully + * unregistered with no live dependents). Also drops any registered hook impls + * for that trait. Returns true if the trait was present. Built-in traits are + * never removed by this (they are not plugin-owned); callers should only pass + * plugin-namespaced ids. + */ + unregisterTrait(traitId: string): boolean { + const def = this.traits.get(traitId); + if (!def || def.builtin) return false; + for (const hookKind of ["guard", "gate", "onEnter", "onExit", "releaseCondition"] as TraitHookKind[]) { + this.hookImpls.delete(traitHookKey(traitId, hookKind)); + } + return this.traits.delete(traitId); + } + /** Resolve a hook implementation. If the trait declares the hook but no impl * is registered, returns a no-op plus an audit warning (degraded, not * crashed). Returns `{ impl: undefined }` with no warning if the trait does diff --git a/packages/engine/src/__tests__/plugin-traits.test.ts b/packages/engine/src/__tests__/plugin-traits.test.ts new file mode 100644 index 0000000000..aa520f593d --- /dev/null +++ b/packages/engine/src/__tests__/plugin-traits.test.ts @@ -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 }): WorkflowIr { + return { + version: "v2", + name: "Custom", + columns: [ + { id: "intake-col", name: "Intake", traits: [{ trait: "intake" }] }, + { + id: "gate-col", + name: "Gate", + traits: [{ trait: pluginTraitId, config: opts?.traitConfig }], + }, + { id: "done-col", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "intake-col" }, + { id: "end", kind: "end", column: "done-col" }, + ], + edges: [{ from: "start", to: "end" }], + } as WorkflowIr; +} + +const PASS_RUNNER: WorkflowCustomNodeRunner = async (): Promise => ({ + outcome: "success", + value: "passed", +}); +const FAIL_RUNNER: WorkflowCustomNodeRunner = async (): Promise => ({ + outcome: "failure", + value: "blocked", +}); + +describe("U8 plugin trait contribution validation (R22, schemaVersion)", () => { + it("rejects a malformed trait manifest (missing schemaVersion / name)", () => { + const errors = validatePluginTraitContribution({ traitId: "x" }); + expect(errors.some((e) => e.includes("schemaVersion is required"))).toBe(true); + expect(errors.some((e) => e.includes("name is required"))).toBe(true); + }); + + it("rejects a sync `guard` hook key (built-in-only, R22)", () => { + const errors = validatePluginTraitContribution({ + traitId: "g", + name: "G", + schemaVersion: 1, + hooks: { guard: true }, + }); + expect(errors.some((e) => e.includes("hooks.guard"))).toBe(true); + }); + + it("rejects a restricted flag (complete / archived, R22)", () => { + const completeErr = validatePluginTraitContribution({ + traitId: "c", + name: "C", + schemaVersion: 1, + flags: { complete: true }, + }); + expect(completeErr.some((e) => e.includes("flags.complete"))).toBe(true); + + const archivedErr = validatePluginTraitContribution({ + traitId: "a", + name: "A", + schemaVersion: 1, + flags: { archived: true }, + }); + expect(archivedErr.some((e) => e.includes("flags.archived"))).toBe(true); + }); + + it("rejects a wrong schemaVersion (versioned extension contract)", () => { + const errors = validatePluginTraitContribution({ traitId: "v", name: "V", schemaVersion: 2 as unknown as 1 }); + expect(errors.some((e) => e.includes("schemaVersion must be 1"))).toBe(true); + }); + + it("accepts a valid async-only gate contribution", () => { + const errors = validatePluginTraitContribution({ + traitId: "approval", + name: "Approval gate", + schemaVersion: 1, + flags: { gate: true }, + hooks: { gate: { mode: "prompt", prompt: "Approve?", gateMode: "blocking" } }, + }); + expect(errors).toEqual([]); + }); +}); + +describe("U8 registry resolution (valid trait resolves like a built-in)", () => { + beforeEach(() => { + freshRegistry(); + }); + afterEach(() => { + __resetTraitRegistryForTests(); + }); + + it("registers a plugin trait under a plugin-namespaced id and resolves through the same lookup", () => { + const registry = getTraitRegistry(); + const contribution: PluginTraitContribution = { + traitId: "approval", + name: "Approval gate", + schemaVersion: 1, + flags: { gate: true }, + hooks: { gate: { mode: "prompt", prompt: "Approve?", gateMode: "blocking" } }, + }; + const ids = registerPluginTraits({ registry, pluginId: "gate-plugin", contributions: [contribution], runCustomNode: PASS_RUNNER }); + const id = pluginTraitRegistryId("gate-plugin", "approval"); + expect(ids).toEqual([id]); + + // Same lookup path as a built-in. + const def = registry.getTrait(id); + expect(def?.flags.gate).toBe(true); + expect(def?.builtin).toBeFalsy(); + // Built-in still resolvable through the same registry. + expect(registry.getTrait("complete")?.flags.complete).toBe(true); + + // The gate hook impl is registered (not a missing-impl degrade). + const resolved = registry.resolveTraitHook(id, "gate"); + expect(resolved.impl).toBeTypeOf("function"); + expect(resolved.warning).toBeUndefined(); + }); + + it("registry rejects a restricted-flag plugin trait as a backstop (R22)", () => { + const registry = getTraitRegistry(); + // The adapter builds a non-builtin definition; the registry enforces R22. + const bad: PluginTraitContribution = { + traitId: "sneaky", + name: "Sneaky", + schemaVersion: 1, + // @ts-expect-error — restricted flag deliberately set to prove the backstop. + flags: { complete: true }, + }; + expect(() => + registerPluginTraits({ registry, pluginId: "p", contributions: [bad], runCustomNode: PASS_RUNNER }), + ).toThrow(/restricted flag/i); + }); +}); + +describe("U8 gate evaluation (blocking fails closed; advisory allows)", () => { + it("blocking gate: a failure verdict does not allow", async () => { + const result = await evaluatePluginGate({ + traitRegistryId: "plugin:gate-plugin:approval", + descriptor: { mode: "prompt", prompt: "Approve?", gateMode: "blocking" }, + task: { id: "T1" } as never, + runCustomNode: FAIL_RUNNER, + }); + expect(result.outcome).toBe("failure"); + }); + + it("blocking gate: a pass verdict allows", async () => { + const result = await evaluatePluginGate({ + traitRegistryId: "plugin:gate-plugin:approval", + descriptor: { mode: "prompt", prompt: "Approve?", gateMode: "blocking" }, + task: { id: "T1" } as never, + runCustomNode: PASS_RUNNER, + }); + expect(result.outcome).toBe("success"); + }); + + it("advisory gate: the handler reports the raw verdict (store layer record-and-allows)", async () => { + // evaluatePluginGate returns the raw runner outcome; the advisory + // "record-and-allow" decision is made at the store guard (see the store + // re-check suite below, which proves an advisory column move commits). + const result = await evaluatePluginGate({ + traitRegistryId: "plugin:gate-plugin:approval", + descriptor: { mode: "prompt", prompt: "FYI", gateMode: "advisory" }, + task: { id: "T1" } as never, + runCustomNode: FAIL_RUNNER, + }); + expect(result.outcome).toBe("failure"); + }); +}); + +describe("U8 store gate re-check (pre-evaluated verdict, KTD-2)", () => { + let rootDir = ""; + let store: TaskStore; + const gateTraitId = pluginTraitRegistryId("gate-plugin", "approval"); + + beforeEach(async () => { + freshRegistry(); + const registry = getTraitRegistry(); + registry.register({ + id: gateTraitId, + name: "Approval gate", + flags: { gate: true }, + hooks: { gate: true }, + builtin: false, + }); + // A LIVE gate hook impl (so the store enforces the recorded verdict rather + // than treating the gate as a degraded/passive no-op). + registry.registerTraitHookImpl(gateTraitId, "gate", () => undefined); + + rootDir = mkdtempSync(join(tmpdir(), "u8-plugin-traits-")); + git(rootDir, "init -b main"); + git(rootDir, "config user.name 'Fusion'"); + git(rootDir, "config user.email 'hi@runfusion.ai'"); + writeFileSync(join(rootDir, "README.md"), "root\n"); + git(rootDir, "add README.md"); + git(rootDir, "commit -m init"); + store = new TaskStore(rootDir, undefined, { inMemoryDb: false }); + await store.init(); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + }); + + afterEach(() => { + try { store?.close(); } catch { /* ignore */ } + if (rootDir) rmSync(rootDir, { recursive: true, force: true }); + __resetTraitRegistryForTests(); + vi.clearAllMocks(); + }); + + async function seedCardInGateWorkflow(config?: Record): Promise { + const def = await store.createWorkflowDefinition({ + name: "Gate WF", + ir: customWorkflowIr(gateTraitId, { traitConfig: config }), + }); + const task = await store.createTask({ description: "card" }); + setSelection(store, task.id, def.id); + setColumn(store, task.id, "intake-col"); + return task.id; + } + + it("blocking gate with NO recorded verdict rejects the move (fail closed)", async () => { + const id = await seedCardInGateWorkflow({ gateMode: "blocking" }); + await expect( + store.moveTask(id, "gate-col", { moveSource: "user" }), + ).rejects.toThrow(/has not been evaluated|did not pass/); + expect((await store.getTask(id)).column).toBe("intake-col"); + }); + + it("blocking gate with a recorded ALLOW verdict permits the move", async () => { + const id = await seedCardInGateWorkflow({ gateMode: "blocking" }); + store.recordPluginGateVerdict(id, "gate-col", { + traitId: gateTraitId, + allow: true, + gateMode: "blocking", + }); + const moved = await store.moveTask(id, "gate-col", { moveSource: "user" }); + expect(moved.column).toBe("gate-col"); + }); + + it("blocking gate with a recorded DENY verdict rejects the move (typed rejection)", async () => { + const id = await seedCardInGateWorkflow({ gateMode: "blocking" }); + store.recordPluginGateVerdict(id, "gate-col", { + traitId: gateTraitId, + allow: false, + gateMode: "blocking", + detail: "reviewer rejected", + }); + await expect( + store.moveTask(id, "gate-col", { moveSource: "user" }), + ).rejects.toThrow(/reviewer rejected/); + expect((await store.getTask(id)).column).toBe("intake-col"); + }); + + it("advisory gate allows the move even without a verdict (record-and-allow)", async () => { + const id = await seedCardInGateWorkflow({ gateMode: "advisory" }); + const moved = await store.moveTask(id, "gate-col", { moveSource: "user" }); + expect(moved.column).toBe("gate-col"); + }); + + it("engine-sourced move bypasses the plugin gate (KTD-9)", async () => { + const id = await seedCardInGateWorkflow({ gateMode: "blocking" }); + // No verdict recorded; an engine move bypasses guards entirely. + const moved = await store.moveTask(id, "gate-col", { moveSource: "engine" }); + expect(moved.column).toBe("gate-col"); + }); +}); + +describe("U8 onEnter hook degradation (card stays, marker cleared, no wedge)", () => { + let rootDir = ""; + let store: TaskStore; + const traitId = pluginTraitRegistryId("notify-plugin", "boom"); + + beforeEach(async () => { + freshRegistry(); + // A plugin trait with an onEnter hook whose impl THROWS. + const registry = getTraitRegistry(); + registry.register({ + id: traitId, + name: "Boom", + flags: { notify: true }, + hooks: { onEnter: true }, + builtin: false, + }); + registry.registerTraitHookImpl(traitId, "onEnter", () => { + throw new Error("plugin onEnter blew up"); + }); + + rootDir = mkdtempSync(join(tmpdir(), "u8-onenter-")); + git(rootDir, "init -b main"); + git(rootDir, "config user.name 'Fusion'"); + git(rootDir, "config user.email 'hi@runfusion.ai'"); + writeFileSync(join(rootDir, "README.md"), "root\n"); + git(rootDir, "add README.md"); + git(rootDir, "commit -m init"); + store = new TaskStore(rootDir, undefined, { inMemoryDb: false }); + await store.init(); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + }); + + afterEach(() => { + try { store?.close(); } catch { /* ignore */ } + if (rootDir) rmSync(rootDir, { recursive: true, force: true }); + __resetTraitRegistryForTests(); + }); + + it("a throwing plugin onEnter does NOT strand the card or wedge the lock", async () => { + // gate-col carries the throwing onEnter trait; move there, then verify a + // subsequent move still succeeds (the lock was not wedged) and the + // transitionPending marker did not stick. + const def = await store.createWorkflowDefinition({ + name: "Boom WF", + ir: customWorkflowIr(traitId), + }); + const task = await store.createTask({ description: "card" }); + setSelection(store, task.id, def.id); + setColumn(store, task.id, "intake-col"); + + // Degraded-not-stranded (KTD-2/R15): the move commits the column change in + // its transaction; plugin post-commit hooks are isolated from the move's + // success path (a throwing onEnter cannot fail the move, strand the card, or + // wedge the lock). The card lands in gate-col regardless of the plugin hook. + const moved = await store.moveTask(task.id, "gate-col", { moveSource: "user" }); + expect(moved.column).toBe("gate-col"); + + // The marker was cleared post-commit — not left dangling. + expect(readTransitionPending(store, task.id)).toBeNull(); + + // The lock is not wedged: a follow-up move proceeds. + const back = await store.moveTask(task.id, "intake-col", { moveSource: "user" }); + expect(back.column).toBe("intake-col"); + }); +}); + +describe("U8 plugin loader aggregation + disable/force-disable (KTD-7)", () => { + let rootDir = ""; + let pluginStore: PluginStore; + let loader: PluginLoader; + let taskRoot = ""; + let store: TaskStore; + + const traitContribution: PluginTraitContribution = { + traitId: "approval", + name: "Approval gate", + schemaVersion: 1, + flags: { gate: true }, + hooks: { gate: { mode: "prompt", prompt: "Approve?", gateMode: "blocking" } }, + }; + const traitRegistryId = pluginTraitRegistryId("gate-plugin", "approval"); + + beforeEach(async () => { + freshRegistry(); + + rootDir = mkdtempSync(join(tmpdir(), "u8-loader-")); + pluginStore = new PluginStore(rootDir, { inMemoryDb: true, centralGlobalDir: rootDir }); + loader = new PluginLoader({ pluginStore, taskStore: { logActivity: vi.fn() } as never }); + await pluginStore.init(); + + taskRoot = mkdtempSync(join(tmpdir(), "u8-loader-tasks-")); + git(taskRoot, "init -b main"); + git(taskRoot, "config user.name 'Fusion'"); + git(taskRoot, "config user.email 'hi@runfusion.ai'"); + writeFileSync(join(taskRoot, "README.md"), "root\n"); + git(taskRoot, "add README.md"); + git(taskRoot, "commit -m init"); + store = new TaskStore(taskRoot, undefined, { inMemoryDb: false }); + await store.init(); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + }); + + afterEach(async () => { + try { store?.close(); } catch { /* ignore */ } + if (taskRoot) rmSync(taskRoot, { recursive: true, force: true }); + const { rm } = await import("node:fs/promises"); + await rm(rootDir, { recursive: true, force: true }); + __resetTraitRegistryForTests(); + }); + + async function loadGatePlugin(): Promise { + const pluginDir = join(rootDir, "plugins"); + await mkdir(pluginDir, { recursive: true }); + const plugin = { + manifest: { id: "gate-plugin", name: "Gate Plugin", version: "1.0.0" }, + state: "installed", + hooks: {}, + traits: [traitContribution], + }; + const path = join(pluginDir, "gate-plugin.mjs"); + await writeFile(path, `const plugin = ${JSON.stringify(plugin, null, 2)}; export default plugin;`); + await pluginStore.registerPlugin({ manifest: plugin.manifest, path }); + await loader.loadAllPlugins(); + } + + it("loader aggregates plugin trait contributions with ownership", async () => { + await loadGatePlugin(); + const traits = loader.getPluginTraits(); + expect(traits).toHaveLength(1); + expect(traits[0].pluginId).toBe("gate-plugin"); + expect(traits[0].trait.traitId).toBe("approval"); + }); + + it("disable with cards in a plugin-trait column is BLOCKED with a typed dependents error", async () => { + await loadGatePlugin(); + const registry = getTraitRegistry(); + registerPluginTraits({ + registry, + pluginId: "gate-plugin", + contributions: loader.getPluginTraits().map((t) => t.trait), + runCustomNode: PASS_RUNNER, + }); + + // Seed a live card in a column using the plugin trait. + const def = await store.createWorkflowDefinition({ name: "Gate WF", ir: customWorkflowIr(traitRegistryId) }); + const task = await store.createTask({ description: "card" }); + setSelection(store, task.id, def.id); + setColumn(store, task.id, "gate-col"); + + const resolveIr = (taskId: string): WorkflowIr | undefined => + store.getTaskWorkflowSelection(taskId)?.workflowId === def.id ? def.ir : undefined; + + const dependents = await findLivePluginTraitDependents({ + store, + resolveTaskWorkflowIr: resolveIr, + pluginTraitIds: [traitRegistryId], + }); + expect(dependents).toHaveLength(1); + expect(dependents[0].taskId).toBe(task.id); + expect(dependents[0].column).toBe("gate-col"); + + // The typed error is the disable block (mirrors the built-in-workflow block). + const err = new PluginTraitHasDependentsError("gate-plugin", dependents); + expect(err.dependents).toHaveLength(1); + expect(err.message).toContain("gate-plugin"); + }); + + it("force-disable degrades the column to passive: hooks become no-ops, cards still movable", async () => { + await loadGatePlugin(); + const registry = getTraitRegistry(); + registerPluginTraits({ + registry, + pluginId: "gate-plugin", + contributions: loader.getPluginTraits().map((t) => t.trait), + runCustomNode: FAIL_RUNNER, // would block if still live + }); + + const def = await store.createWorkflowDefinition({ name: "Gate WF", ir: customWorkflowIr(traitRegistryId, { traitConfig: { gateMode: "blocking" } }) }); + const task = await store.createTask({ description: "card" }); + setSelection(store, task.id, def.id); + setColumn(store, task.id, "intake-col"); + + // Before degrade: the gate hook impl is registered (not a missing-impl no-op). + expect(registry.resolveTraitHook(traitRegistryId, "gate").warning).toBeUndefined(); + + // Force-disable: degrade the trait's hooks to no-ops. + const degraded = degradePluginTraits(registry, [traitRegistryId]); + expect(degraded).toContain(traitRegistryId); + + // The trait definition still resolves (column not bricked) but the hook is + // now the degraded no-op + audit warning path. + expect(registry.getTrait(traitRegistryId)).toBeDefined(); + const resolved = registry.resolveTraitHook(traitRegistryId, "gate"); + expect(resolved.warning?.kind).toBe("missing-hook-impl"); + + // Card is still movable into the degraded column with NO recorded verdict: + // the store guard sees the degraded (warning) gate and treats it as passive + // (KTD-7 — cards remain movable). A live (non-degraded) blocking gate would + // have rejected this move for lack of a verdict. + const moved = await store.moveTask(task.id, "gate-col", { moveSource: "user" }); + expect(moved.column).toBe("gate-col"); + }); +}); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 0f5fadcf31..9eb88b2391 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -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 diff --git a/packages/engine/src/plugin-runner.ts b/packages/engine/src/plugin-runner.ts index 829a55ead8..4e2f82926e 100644 --- a/packages/engine/src/plugin-runner.ts +++ b/packages/engine/src/plugin-runner.ts @@ -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(); + /** 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(); + 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 { + 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})`); diff --git a/packages/engine/src/plugin-trait-adapter.ts b/packages/engine/src/plugin-trait-adapter.ts new file mode 100644 index 0000000000..79488192e3 --- /dev/null +++ b/packages/engine/src/plugin-trait-adapter.ts @@ -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::` 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[] = [ + "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, + 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; + runCustomNode: WorkflowCustomNodeRunner; +}): Promise { + 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 } + | 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; + /** 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 { + 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; +} diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 10bdd9c090..d825c3fba9 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -78,6 +78,9 @@ export type { PluginLogger, PluginSkillContribution, PluginWorkflowStepContribution, + PluginTraitContribution, + PluginTraitHookDescriptor, + PluginTraitFlags, PluginPromptSurface, PluginPromptContribution, PluginPromptContributions, @@ -95,6 +98,15 @@ export type { import type { FusionPlugin } from "@fusion/core"; +// Re-export the trait contribution validator + constants so plugin authors can +// validate their trait manifests with the same rules the engine enforces (U8). +export { + validatePluginTraitContribution, + PLUGIN_TRAIT_RESTRICTED_FLAGS, + PLUGIN_TRAIT_ALLOWED_HOOK_POINTS, + PLUGIN_TRAIT_SCHEMA_VERSION, +} from "@fusion/core"; + const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; export function validatePluginManifest(manifest: unknown): { valid: boolean; errors: string[] } {