feat(engine): plugin-contributed traits — async-only hooks, pre-evaluated gates, live-dependent disable guard (U8)
This commit is contained in:
@@ -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:<pluginId>:<traitId>`, 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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
83
packages/core/src/plugin-gate-verdict.ts
Normal file
83
packages/core/src/plugin-gate-verdict.ts
Normal file
@@ -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:<pluginId>:<traitId>`). */
|
||||
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;
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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:<pluginId>:<traitId>`. */
|
||||
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<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
// 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)) {
|
||||
|
||||
@@ -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<TaskStoreEvents> {
|
||||
private watcher: FSWatcher | null = null;
|
||||
/** In-memory cache of tasks for diffing watcher events */
|
||||
private taskCache: Map<string, Task> = 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<string, Map<string, PluginGateVerdict[]>> = new Map();
|
||||
/** Paths recently written by in-process mutations (suppresses duplicate events) */
|
||||
private recentlyWritten: Set<string> = new Set();
|
||||
/** Pending debounce timers keyed by task ID */
|
||||
@@ -5778,6 +5793,48 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
`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<PluginGateVerdict, "recordedAt"> & { 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;
|
||||
|
||||
@@ -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
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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[] } {
|
||||
|
||||
Reference in New Issue
Block a user