diff --git a/.changeset/workflow-extension-plugins.md b/.changeset/workflow-extension-plugins.md new file mode 100644 index 0000000000..6abdeaf9df --- /dev/null +++ b/.changeset/workflow-extension-plugins.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Add workflow extension plugin contracts for move policies, work engines, node handlers, task verdict providers, auto-merge facts, and shared board action services. diff --git a/CONCEPTS.md b/CONCEPTS.md index 5b4a928f4a..dc7dbcbfc1 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -174,6 +174,9 @@ A Bundled Plugin must be registered in several independently maintained surfaces ### Plugin Entry The single loadable file persisted as a plugin's path and dynamically imported by the loader. The contract is strict: a package directory is never a valid entry (ESM cannot import directories), so every install surface must resolve a concrete file before persisting, preferring the shipped bundle, then a prebuilt output, then raw workspace source. Legacy registrations that stored a directory are healed in place — re-pointed at a resolved entry — the next time the plugin is enabled or auto-installed. +### Workflow Extension +A plugin-contributed workflow capability registered through the engine rather than hardcoded into core workflow logic: column metadata, movement policies, column work engines, workflow node handlers, task verdict providers, or merge-routing facts. A Workflow Extension is opt-in by workflow metadata and must degrade or park by an explicit fallback policy when its plugin is disabled or missing, preserving the Default workflow baseline when no extension is active. + ## Workflow columns & traits *Behind the `experimentalFeatures.workflowColumns` flag. With the flag off, the legacy fixed pipeline (the closed column enum + `VALID_TRANSITIONS`) is authoritative and unchanged.* diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 2c25c2b751..24b2f8e315 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -21,6 +21,7 @@ A comprehensive guide to creating Fusion plugins that extend the task board with 15. [Registering Skills](#15-registering-skills) 16. [Registering Workflow Steps](#16-registering-workflow-steps) 16.5. [Contributing Column Traits](#165-contributing-column-traits) +16.6. [Contributing Workflow Extensions](#166-contributing-workflow-extensions) 17. [Contributing Prompt Modifications](#17-contributing-prompt-modifications) 18. [Plugin Binary Setup Hooks](#18-plugin-binary-setup-hooks) @@ -1605,6 +1606,70 @@ 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. +## 16.6. Contributing Workflow Extensions + +Workflow extensions let plugins participate in engine and workflow decisions +without replacing the base engine. Each extension is registered under +`plugin::` and may be referenced from workflow IR +`extensions` metadata on columns or nodes. + +Use `WORKFLOW_EXTENSION_SCHEMA_VERSION` from `@fusion/plugin-sdk` and declare +extensions on the plugin object: + +```typescript +import { + WORKFLOW_EXTENSION_SCHEMA_VERSION, + type WorkflowExtensionContribution, +} from "@fusion/plugin-sdk"; + +const workflowExtensions: WorkflowExtensionContribution[] = [ + { + extensionId: "security-move-policy", + name: "Security Move Policy", + kind: "move-policy", + schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION, + fallback: "failClosed", + evaluate: async ({ task, fromColumn, toColumn, actor }) => { + if (toColumn === "done" && actor?.kind !== "human") { + return { + allowed: false, + reason: "human approval required", + message: `Cannot move ${task.id} to done without human approval`, + }; + } + return { allowed: true }; + }, + }, +]; + +export default definePlugin({ + manifest: { id: "my-plugin", name: "My Plugin", version: "1.0.0" }, + workflowExtensions, +}); +``` + +Supported kinds: + +| Kind | Purpose | Binding | +|---|---|---| +| `column-metadata` | Typed metadata schema for workflow columns | Column `extensions` metadata | +| `move-policy` | Async pre-move policy that can allow or reject a valid transition | Global evaluator | +| `work-engine` | Claims execution for a column before the built-in executor starts | Column `extensions` metadata | +| `node-handler` | Handles an extension-marked workflow node before the built-in handler | Node `extensions` metadata | +| `verdict-provider` | Adds async task-completion verdicts before `fn_task_done` can finish | Global evaluator | +| `merge-fact-provider` | Adds route/fact inputs to auto-merge request initialization | Global evaluator | + +Fallback behavior controls what happens when an extension handler fails: + +- `degradeToDefault` — continue through the built-in behavior. +- `parkNeedsAttention` — block or park the action with a retryable/manual signal. +- `failClosed` — block the action with a fail-closed diagnostic. + +Workflow IR extension metadata is preserved only on v2 workflows. Keys must use +the `plugin::` form, and values must be JSON objects. If +the extension is registered and declares a `configSchema`, the engine validates +the metadata fields during IR validation. + ## 17. Contributing Prompt Modifications Prompt contributions let a plugin inject additional instructions into specific prompt surfaces. diff --git a/docs/plans/2026-06-08-001-feat-workflow-extension-plugins-plan.md b/docs/plans/2026-06-08-001-feat-workflow-extension-plugins-plan.md new file mode 100644 index 0000000000..7880dd439f --- /dev/null +++ b/docs/plans/2026-06-08-001-feat-workflow-extension-plugins-plan.md @@ -0,0 +1,293 @@ +--- +title: "feat: Add workflow extension plugins" +type: feat +status: active +date: 2026-06-08 +depth: deep +origin: none +--- + +# feat: Add workflow extension plugins + +## Summary + +Make the workflow engine extensible enough that optional workflow packages can add board semantics, workflow node behavior, review gates, merge routing, and column-owned work engines through plugins. The shared engine remains the compatibility layer: built-in workflows keep today’s default behavior, while installed plugins contribute extra workflow capabilities without product-specific branches in core engine code. + +--- + +## Problem Frame + +The current workflow stack already supports custom columns, traits, settings, graph nodes, PR nodes, plugin traits, plugin step parsers, plugin workflow steps, and plugin-hosted interactive AI sessions. A local workflow comparison shows the next extension pressure is not another fixed workflow, but a set of policies that currently want to cross engine boundaries: actor-aware movement, typed column roles, column-bound work engines, verdict-backed review gates, verdict-aware merge routing, shared board actions, and specialized PR response dispatch. + +Hardcoding those policies into the engine would fork the lifecycle model. The engine should instead define extension contracts that plugins can register, validate, degrade, and observe. The default engine path must stay byte-identical when no plugin extension is installed. + +--- + +## Requirements + +- R1. Workflow IR can carry plugin-defined column metadata and column work-engine bindings without adding product-specific fields to the core workflow schema. +- R2. Plugins can contribute workflow node handlers and column work engines that run through engine-owned dispatch seams, with plugin absence degrading or parking according to the declared policy. +- R3. Plugins can contribute movement policies that evaluate actor/source-aware moves without bypassing existing workflow adjacency, task locks, or hard-cancel semantics. +- R4. Plugins can contribute review or verdict providers that persist task-scoped outcomes and expose them to workflow gates, merge gates, and recovery. +- R5. Auto-merge trigger layers consult one route-producing chokepoint that can combine existing global/per-task settings with plugin-contributed gate facts. +- R6. Shared board actions used by HTTP routes and agent tools flow through engine-level service functions rather than duplicated route logic. +- R7. Plugin extension disable/uninstall paths are safe: live dependents block normal disable, force disable degrades executable hooks and policies without stranding tasks. +- R8. Default workflows and existing custom workflows remain unchanged when no plugin extension is active. +- R9. The implementation covers every known surface: workflow validation, store moves, agent tools, dashboard routes, scheduler/runtime dispatch, PR nodes, merge triggers, self-healing, and plugin lifecycle. + +--- + +## Key Technical Decisions + +- KTD-1. Use a generic workflow extension registry instead of core schema fields for optional semantics. Column roles, lock markers, work-engine bindings, and actor policies should be plugin-namespaced contributions referenced from open metadata, not first-class product-specific properties. +- KTD-2. Engine owns dispatch; plugins own behavior. The engine should expose narrow `WorkflowWorkEngine`, `WorkflowNodeHandler`, `WorkflowMovePolicy`, `TaskVerdictProvider`, and `AutoMergeFactProvider` seams. Plugins register implementations; runtime code calls registries through typed adapters. +- KTD-3. Default behavior is the absence baseline. Built-in workflows, legacy default columns, additive global/per-task auto-merge behavior, PR workflow nodes, and existing plugin trait semantics must remain the fallback when no extension claims a task or workflow. +- KTD-4. Extension failures degrade by contract. A missing plugin in a fresh dispatch can fall back when the extension declares `degradeToDefault`; a missing plugin for a parked artifact-consuming state should park with a needs-attention diagnostic. The policy is explicit per contribution, not guessed by the caller. +- KTD-5. Review/verdict data is a generic task-scoped contract. The engine should support task-keyed verdict runs independent of mission validators, with write-once terminal outcomes, identity-aware pass rules, invalidation, and re-drive support. +- KTD-6. Merge routing returns destinations, not booleans. Trigger gates need to distinguish auto-enqueue, workflow subgraph, manual-required, and blocked so manual-required tasks are parked deliberately rather than skipped. +- KTD-7. Shared actions live below routes and tools. Board movement, plan rejection, board creation/seed, and board deletion/rehome should be callable by dashboard routes and agent tools through one engine service layer. +- KTD-8. Plugin lifecycle must see live workflow dependents. Existing plugin trait dependent checks are the pattern: extension contributions need equivalent dependent discovery before disable/uninstall and a force-degrade path with audit records. + +--- + +## High-Level Technical Design + +```mermaid +flowchart TB + Plugin[Enabled plugin] --> Runner[PluginRunner contribution caches] + Runner --> Registry[WorkflowExtensionRegistry] + + Registry --> Metadata[Column metadata validators] + Registry --> Move[Move policy registry] + Registry --> Work[Work engine registry] + Registry --> Node[Node handler registry] + Registry --> Verdict[Task verdict registry] + Registry --> Merge[Merge fact providers] + + Runtime[Workflow runtime / scheduler] --> Work + Graph[WorkflowGraphExecutor] --> Node + Store[TaskStore.moveTask] --> Move + Reviewer[Review gate driver] --> Verdict + Merger[Auto-merge trigger gates] --> Merge + Merge --> Route[auto-enqueue / workflow-subgraph / manual-required / blocked] +``` + +```mermaid +sequenceDiagram + participant Move as moveTask caller + participant Store as TaskStore + participant Registry as WorkflowExtensionRegistry + participant Policy as Plugin move policy + participant Hooks as Trait hooks + + Move->>Store: request column move with actor/source + Store->>Store: resolve workflow adjacency + Store->>Registry: policies for workflow metadata + Registry->>Policy: evaluate actor/source move + Policy-->>Registry: allow or typed rejection + Registry-->>Store: decision + Store->>Store: commit move and transitionPending + Store->>Hooks: async plugin onExit/onEnter hooks + Hooks-->>Store: complete or audited degrade +``` + +```mermaid +flowchart TB + InReview[Task enters review column] --> Provider{Verdict provider registered?} + Provider -->|no| Existing[Existing review / PR behavior] + Provider -->|yes| Run[Start task-scoped verdict run] + Run --> Eval[Plugin or engine evaluator] + Eval -->|pass| Pass[Write pass verdict] + Eval -->|fail| Fail[Write fail verdict and route rework] + Eval -->|blocked/error| Park[Park or needs-attention] + Pass --> MergeGate[Auto-merge route chokepoint] + MergeGate --> Auto[auto-enqueue] + MergeGate --> Subgraph[workflow subgraph] + MergeGate --> Manual[manual-required] + MergeGate --> Blocked[blocked] +``` + +--- + +## Scope Boundaries + +### In Scope + +- Add generic plugin contribution contracts for workflow metadata, move policies, work engines, node handlers, verdict providers, and merge facts. +- Add validation and registry plumbing so workflow IR can reference plugin-owned semantics safely. +- Adapt runtime, graph execution, store movement, review gates, auto-merge triggers, and shared board actions to consume those contracts. +- Preserve default workflow behavior when no extension contribution is present. +- Add tests for lifecycle parity, plugin disable/degrade, and cross-surface behavior. + +### Deferred to Follow-Up Work + +- Moving every existing built-in PR node into an external plugin. This plan creates the seam and can migrate individual behaviors after compatibility is proven. +- A marketplace or remote plugin distribution model beyond existing plugin install/enable behavior. +- New dashboard design for authoring complex plugin policies. This plan can expose metadata to existing workflow/board surfaces, but rich editors can follow. + +### Out of Scope + +- Replacing the current workflow graph executor. +- Rewriting the merger, PR entity store, or mission validator. +- Changing task identity, board persistence, or the default column enum. +- Creating tier-specific language or behavior in core engine code. + +--- + +## Implementation Units + +### U1. Workflow Extension Contribution Contract + +- **Goal:** Define the plugin-facing contribution types and manifest metadata for workflow extensions. +- **Requirements:** R1, R2, R4, R5, R7, R8. +- **Dependencies:** None. +- **Files:** `packages/core/src/plugin-types.ts`, `packages/core/src/workflow-extension-types.ts` (new), `packages/core/src/__tests__/plugin-contribution-types.test.ts`, `packages/cli/src/__tests__/plugin-sdk-export.test.ts`, `docs/PLUGIN_AUTHORING.md`, `docs/plugins/external-authoring.md`. +- **Approach:** Add a `workflowExtensions` contribution group covering column metadata schemas, move policies, work engines, node handlers, task verdict providers, and merge fact providers. Keep identifiers plugin-namespaced, versioned, and additive. Contributions should declare fallback posture: `degradeToDefault`, `parkNeedsAttention`, or `failClosed`. +- **Patterns to follow:** `PluginTraitContribution`, `PluginWorkflowStepContribution`, `PluginPromptContributions`, `PluginSetupHooks`, and `validatePluginTraitContribution`. +- **Test scenarios:** A valid contribution with every extension kind validates and exports through the SDK; invalid ids reject; missing schema version rejects; reserved built-in ids reject; unsupported fallback posture rejects; a plugin with no workflow extensions remains valid. +- **Verification:** Plugin manifest validation, SDK export tests, and authoring docs agree on the same contribution shape. + +### U2. Extension Registry And PluginRunner Integration + +- **Goal:** Aggregate enabled plugin workflow extensions into a hot-reloadable registry with safe disable/degrade behavior. +- **Requirements:** R2, R7, R8, R9. +- **Dependencies:** U1. +- **Files:** `packages/core/src/workflow-extension-registry.ts` (new), `packages/engine/src/plugin-runner.ts`, `packages/engine/src/plugin-workflow-extension-adapter.ts` (new), `packages/core/src/__tests__/plugin-loader-contributions.test.ts`, `packages/engine/src/__tests__/plugin-workflow-extension-adapter.test.ts`. +- **Approach:** Mirror the plugin trait and parser adapters. `PluginRunner` caches workflow extension contributions, registers them on load/reload, unregisters cleanly when no live dependents exist, and force-degrades executable parts when requested. Registry lookups return typed degraded results so callers audit and continue instead of crashing. +- **Patterns to follow:** `syncPluginTraits`, `disablePluginTraits`, `registerPluginStepParsers`, `degradePluginTraits`, and `recordRunAuditEvent` usage for plugin degradation. +- **Test scenarios:** Plugin load registers all contribution kinds; reload refreshes handlers without duplicate ids; normal disable with live dependents returns a typed dependent error; force disable degrades handlers and emits audit; disabling a plugin with no dependents unregisters cleanly; registry lookup for a missing extension returns the declared fallback posture. +- **Verification:** Registry tests prove plugin lifecycle changes cannot leave a task referencing an executable handler that silently disappears. + +### U3. Plugin-Namespace Workflow IR Metadata + +- **Goal:** Let workflow IR carry plugin-owned column and node metadata without adding optional feature fields to `WorkflowIrColumn`. +- **Requirements:** R1, R2, R8, R9. +- **Dependencies:** U1, U2. +- **Files:** `packages/core/src/workflow-ir-types.ts`, `packages/core/src/workflow-ir.ts`, `packages/core/src/workflow-ir-resolver.ts`, `packages/core/src/__tests__/workflow-ir.test.ts`, `packages/core/src/__tests__/workflow-ir-resolver.test.ts`, `packages/core/src/__tests__/workflow-ir-extension-metadata.test.ts` (new). +- **Approach:** Add an open `extensions` bag to workflow columns and nodes, keyed by `plugin::`. Validation resolves schemas from the registry when available and validates shape; missing plugins use fallback rules and preserve data. Built-in workflows should serialize byte-identically unless they intentionally use an extension. +- **Patterns to follow:** workflow custom field and workflow setting validation, plugin trait namespacing, and step parser fail-closed behavior. +- **Test scenarios:** Default built-in workflow has no extension metadata; a workflow with valid plugin metadata parses; malformed metadata rejects with field-specific errors when the plugin schema is available; metadata for a disabled plugin is preserved but marked degraded; v1-to-v2 upgrade does not synthesize extension bags; workflow save rejects attempts to use untrusted built-in namespaces. +- **Verification:** Workflow parse/resolution tests cover active plugin, missing plugin, malformed metadata, and no-extension fallback. + +### U4. Actor-Aware Movement Policy Seam + +- **Goal:** Move actor/source-specific column movement rules out of hardcoded workflow transition logic and into plugin-contributed policies. +- **Requirements:** R3, R7, R8, R9. +- **Dependencies:** U2, U3. +- **Files:** `packages/core/src/workflow-transitions.ts`, `packages/core/src/store.ts`, `packages/core/src/workflow-extension-registry.ts`, `packages/core/src/__tests__/workflow-transitions.test.ts`, `packages/core/src/__tests__/move-task-characterization.test.ts`, `packages/engine/src/__tests__/agent-tools-board-routing.test.ts` (new). +- **Approach:** Extend `moveTask` context with actor/source details already implied by routes, agent tools, and engine moves. Resolve base workflow adjacency first, then ask registered move policies for allow/reject decisions. Engine/recovery moves keep explicit bypass semantics and still log why guards were bypassed. +- **Patterns to follow:** `resolveColumnAdjacency`, `VALID_TRANSITIONS`, plugin trait gate evaluation outside locks, `bypassGuards`, and `moveSource`. +- **Test scenarios:** Human moves on a workflow with no move policy behave as today; agent tool moves include agent identity; a policy can reject skip-forward with a typed reason; engine-sourced rehome/reset can bypass policies only with `bypassGuards`; plugin policy throw degrades according to contribution posture; hard cancel from `in-progress` to `todo` remains authoritative. +- **Verification:** Store and agent-tool tests prove movement policy applies consistently across dashboard, tools, and engine moves. + +### U5. Column Work Engine Dispatch Seam + +- **Goal:** Allow a workflow column to dispatch work through a plugin-provided work engine instead of the default task executor. +- **Requirements:** R2, R7, R8, R9. +- **Dependencies:** U2, U3. +- **Files:** `packages/engine/src/workflow-task-runtime.ts`, `packages/engine/src/workflow-node-handlers.ts`, `packages/engine/src/plugin-work-engine-dispatch.ts` (new), `packages/engine/src/scheduler.ts`, `packages/engine/src/__tests__/workflow-work-engine-dispatch.test.ts` (new), `packages/engine/src/__tests__/scheduler.test.ts`. +- **Approach:** Add a runtime dispatch probe before default execution: resolve the task workflow, current column metadata, and any registered work-engine binding. The registered work engine receives a task context, effective settings, actor identity, and a session factory if it needs interactive AI. Fresh dispatch can degrade to default only when declared; parked release states park on missing plugin when the extension owns the artifact-consuming continuation. +- **Patterns to follow:** plugin interactive session factory, `WorkflowTaskRuntime` plan, `PluginRunner` caches, `createDefaultNodeHandlers`, and PR node dependency injection. +- **Test scenarios:** No work-engine binding runs the default engine; a registered work engine claims the task and prevents duplicate default execution; missing plugin with `degradeToDefault` runs default with audit; missing plugin with `parkNeedsAttention` parks with diagnostic; headless/interactive posture is carried per run context; abort cancels plugin-owned sessions through the same hard-cancel path. +- **Verification:** Scheduler/runtime tests show one owner per task run and no silent fallback when the plugin declared parking. + +### U6. Plugin-Contributed Workflow Node Handlers + +- **Goal:** Let plugins bind specialized node kinds or node-handler overrides through the workflow graph executor. +- **Requirements:** R2, R7, R8, R9. +- **Dependencies:** U2, U3. +- **Files:** `packages/engine/src/workflow-node-handlers.ts`, `packages/engine/src/workflow-graph-executor.ts`, `packages/engine/src/plugin-workflow-node-adapter.ts` (new), `packages/engine/src/__tests__/workflow-node-handlers.test.ts`, `packages/engine/src/__tests__/workflow-graph-executor.test.ts`. +- **Approach:** Extend handler creation to consult plugin node-handler registry for namespaced node kinds or metadata-bound handlers. Preserve built-in handlers for `pr-create`, `pr-respond`, `pr-merge`, `parse-steps`, `foreach`, and core prompt/script/gate behavior. Plugin handlers run through engine-owned adapters that provide task context, audit, cancellation, and limited host capabilities. +- **Patterns to follow:** `PrNodeDeps`, `buildPrNodeDeps`, `createDefaultNodeHandlers`, and plugin trait hook execution via synthetic workflow nodes. +- **Test scenarios:** Built-in PR nodes still route to built-in handlers; a plugin node kind executes and returns graph outcomes; handler throw maps to `failure` and audit; disabled plugin handler follows fallback posture; plugin cannot override built-in node kinds unless a contribution explicitly targets an extension metadata key; cancellation signal reaches long-running plugin handler. +- **Verification:** Graph tests show plugin node outcomes participate in normal edge routing without special cases in the executor. + +### U7. Task-Scoped Verdict Provider And Review Gate + +- **Goal:** Add a generic task verdict store and review gate driver that plugins can use for workflow-specific approval semantics. +- **Requirements:** R4, R5, R7, R9. +- **Dependencies:** U1, U2, U3. +- **Files:** `packages/core/src/task-verdict-store.ts` (new), `packages/core/src/db.ts`, `packages/core/src/types.ts`, `packages/engine/src/reviewer-gate.ts` (new), `packages/core/src/__tests__/task-verdict-store.test.ts` (new), `packages/engine/src/__tests__/reviewer-gate.test.ts` (new). +- **Approach:** Generalize task-keyed reviewer runs into a `TaskVerdictStore`: start, complete write-once, invalidate, list stale running, and get latest covering verdict. Providers can evaluate with read-only AI, plugin sessions, or deterministic logic. Pass verdicts can require an expected writer identity; fail/blocked/error can be written by recovery. +- **Execution note:** Characterization-first around existing mission validator behavior and in-review stall behavior before adding generic verdict gating. +- **Patterns to follow:** mission `Validator Run` methods, PR response run fail-safe posture, task log diagnostics, and `resolveMaxReworkCycles`. +- **Test scenarios:** Starting a run persists running status; completing a pass with matching identity succeeds; completing a pass with missing/mismatched identity rejects; terminal verdict cannot be rewritten; fail verdict can move the task backward through move policies; error verdict does not block recovery re-drive; stale running verdicts reap to error. +- **Verification:** Verdict store and gate tests prove write-once, identity, recovery, and rework-budget behavior. + +### U8. Auto-Merge Route Chokepoint With Extension Facts + +- **Goal:** Replace scattered merge-trigger booleans with one route-producing predicate that can include plugin verdict and workflow-mode facts. +- **Requirements:** R5, R8, R9. +- **Dependencies:** U7. +- **Files:** `packages/core/src/auto-merge-gate.ts` (new), `packages/engine/src/auto-merge-gate-engine.ts` (new), `packages/core/src/task-merge.ts`, `packages/engine/src/project-engine.ts`, `packages/engine/src/self-healing.ts`, `packages/core/src/__tests__/auto-merge-gate.test.ts` (new), `packages/engine/src/__tests__/auto-merge-gate-engine.test.ts` (new). +- **Approach:** Keep existing global/per-task auto-merge processing semantics additive, but return `auto-enqueue`, `workflow-subgraph`, `manual-required`, or `blocked`. Engine binding resolves the task workflow, whether a workflow-owned merge node is present, extension facts, and latest task verdict. Every auto-merge trigger consults this same binding. +- **Patterns to follow:** `allowsAutoMergeProcessing`, `ProjectEngine.requestInterpreterMerge`, PR `auto-merge` gate handler, and the per-task auto-merge override solution. +- **Test scenarios:** Global on plus unset task auto-enqueues; global on plus task false routes manual-required; global off plus task true still processes; global off plus unset routes manual-required; workflow-owned merge routes to workflow subgraph; non-pass verdict blocks; pass verdict routes according to merge mode; self-healing and moved-to-review triggers produce the same route. +- **Verification:** Core predicate tests and engine trigger tests show manual-required tasks are not starved and plugin facts are consulted consistently. + +### U9. Shared Board Action Services For Routes And Agent Tools + +- **Goal:** Move duplicated board/task action logic into engine services that dashboard routes and agent tools share. +- **Requirements:** R6, R9. +- **Dependencies:** U4, U5. +- **Files:** `packages/engine/src/board-actions.ts` (new), `packages/engine/src/agent-tools.ts`, `packages/dashboard/src/routes/register-task-workflow-routes.ts`, `packages/dashboard/src/routes/register-board-routes.ts`, `packages/engine/src/__tests__/board-actions.test.ts` (new), `packages/engine/src/__tests__/agent-tools-board-actions.test.ts` (new), `packages/dashboard/src/__tests__/board-routes.test.ts`. +- **Approach:** Extract rehome, delete/rehome, plan reject, and board create/seed behaviors into service functions with structured results. Routes map results to HTTP responses; tools map them to tool output. Service functions use `moveSource: "engine"` and `bypassGuards` only for system-owned moves that must bypass human/agent movement policy. +- **Patterns to follow:** route helper style, task log entries, agent binding release, board store APIs, and `superviseSpawn` rules where subprocesses are involved. +- **Test scenarios:** Route and tool move a task to a board through the same service; rehome releases execution-agent bindings; deleting a board rehomes tasks or refuses when no safe target exists; plan rejection clears status and removes `PROMPT.md`; create board seeds optional team bindings idempotently; service result mapping is route/tool-specific without duplicated business logic. +- **Verification:** Route and tool tests prove one implementation drives both surfaces. + +### U10. Documentation, Compatibility, And Release Boundary + +- **Goal:** Document the extension model and preserve compatibility expectations for plugin authors and downstream users. +- **Requirements:** R7, R8, R9. +- **Dependencies:** U1-U9. +- **Files:** `docs/PLUGIN_AUTHORING.md`, `docs/plugin-management.md`, `docs/workflow-steps.md`, `docs/architecture.md`, `CONCEPTS.md`, `.changeset/workflow-extension-plugins.md` (new), `packages/core/src/__tests__/workflow-parity.test.ts`, `packages/core/src/__tests__/workflow-parity-summary.test.ts`. +- **Approach:** Document workflow extension contribution types, fallback posture, dependent checks, force-degrade behavior, and examples for move policy, work engine, node handler, verdict provider, and merge fact provider. Add a patch changeset because published `@runfusion/fusion` plugin and workflow behavior changes. +- **Patterns to follow:** plugin authoring sections for traits, workflow steps, prompt contributions, binary setup hooks, and bundled plugin drift documentation. +- **Test scenarios:** Documentation inventory tests stay current; default workflow parity summary still reports unchanged default columns and transitions; plugin authoring examples use plugin-namespaced ids; changeset names `@runfusion/fusion` as patch. +- **Verification:** Docs and parity tests establish that extensions are opt-in and the default engine remains the shared baseline. + +--- + +## System-Wide Impact + +This plan changes the extension boundary of the engine, not the default workflow contract. The highest-risk surfaces are task moves, task execution ownership, review gating, and auto-merge routing because they participate in hard lifecycle invariants. The design keeps those invariants in the engine and lets plugins provide facts, handlers, and policies through typed registries. + +Affected parties: + +- Plugin authors get durable extension points for workflow semantics instead of relying on routes, prompt contributions, or hardcoded engine changes. +- Engine maintainers get one shared lifecycle with opt-in extension registries, rather than a branch per workflow package. +- Dashboard and CLI surfaces get shared services for board actions, reducing behavior drift between UI and agent tools. +- Users keep default workflow behavior when no extension is installed, and receive explicit diagnostics when an installed extension disappears mid-lifecycle. + +--- + +## Risks & Dependencies + +- Extension metadata can become an untyped escape hatch. Mitigation: metadata must reference registered versioned schemas; unknown plugin data is preserved but not executed. +- Plugin handlers can wedge lifecycle work if they run in task locks. Mitigation: executable plugin code runs outside locks through adapters; in-lock checks consume cached verdicts or prior gate records. +- Merge routing can regress manual-required semantics. Mitigation: use a route-producing predicate and test every global/per-task combination at each trigger layer. +- Force-disable can hide important behavior. Mitigation: block normal disable with live dependents; force-disable audits affected tasks and degrades executable hooks to passive only where the contribution declares it safe. +- Optional work engines can bypass security controls. Mitigation: engine-owned adapters provide cancellation, audit, worktree policy, and secret-scan/pre-push guard hooks where a plugin-owned session can mutate code. + +--- + +## Documentation / Operational Notes + +- The implementation should update plugin authoring docs alongside the contribution types, not after the fact. +- Because the change affects published `@runfusion/fusion` behavior, add a patch changeset with the implementation. +- No external third-party integration is introduced by this plan; upstream release/checksum evidence is not required unless a later implementation adds a new managed binary or external CLI. + +--- + +## Sources & Research + +- `STRATEGY.md` anchors the work in the ecosystem and adaptability track. +- `CONCEPTS.md` defines Workflow Setting, Default workflow, Trait, Column agent, transitionPending, PR entity, Review-response loop, and plugin concepts that this plan extends. +- `docs/plans/2026-06-07-001-refactor-workflow-runtime-cutover-plan.md` establishes the workflow engine as the execution owner. +- `docs/plans/2026-06-03-002-feat-workflow-interpreter-cutover-plan.md` records the earlier seam-based graph cutover direction and why legacy re-entry should disappear. +- `packages/core/src/plugin-types.ts`, `packages/engine/src/plugin-runner.ts`, `packages/engine/src/plugin-trait-adapter.ts`, and `packages/engine/src/plugin-parser-adapter.ts` provide the existing contribution, registry, and degrade patterns. +- `packages/core/src/workflow-ir-types.ts`, `packages/core/src/workflow-ir.ts`, `packages/core/src/workflow-transitions.ts`, and `packages/core/src/store.ts` are the current workflow schema, validation, transition, and move surfaces. +- `packages/engine/src/workflow-node-handlers.ts`, `packages/engine/src/pr-nodes.ts`, and `packages/core/src/builtin-pr-workflow-ir.ts` provide graph node and PR workflow patterns to keep built-in and plugin handlers aligned. +- `docs/solutions/architecture-patterns/observable-long-running-agent-turns-through-blocking-plugin-route-seam.md` supports the session dispatch posture: keep pull-based terminal semantics and add observable, detached, void-safe execution for long-running plugin sessions. +- `docs/solutions/integration-issues/bundled-plugin-registration-drift.md` informs the lifecycle/dependent-check risk: plugin registrations must have drift guards and explicit fallback behavior. diff --git a/packages/core/src/__tests__/board-action-services.test.ts b/packages/core/src/__tests__/board-action-services.test.ts new file mode 100644 index 0000000000..8fe92f0c3b --- /dev/null +++ b/packages/core/src/__tests__/board-action-services.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; +import { createBoardActionServices } from "../board-action-services.js"; + +describe("board action services", () => { + it("delegates moves through the canonical TaskStore moveTask path", async () => { + const task = { id: "FN-ACTION", column: "todo" }; + const store = { + moveTask: vi.fn().mockResolvedValue(task), + updateTask: vi.fn(), + }; + + await expect(createBoardActionServices(store as any).moveTask({ + taskId: "FN-ACTION", + column: "todo", + preserveProgress: true, + source: "engine", + })).resolves.toBe(task); + + expect(store.moveTask).toHaveBeenCalledWith("FN-ACTION", "todo", { + preserveProgress: true, + moveSource: "engine", + }); + }); + + it("delegates updates through the canonical TaskStore updateTask path", async () => { + const task = { id: "FN-ACTION", title: "Updated" }; + const store = { + moveTask: vi.fn(), + updateTask: vi.fn().mockResolvedValue(task), + }; + + await expect(createBoardActionServices(store as any).updateTask({ + taskId: "FN-ACTION", + updates: { title: "Updated" }, + })).resolves.toBe(task); + + expect(store.updateTask).toHaveBeenCalledWith("FN-ACTION", { title: "Updated" }); + }); +}); diff --git a/packages/core/src/__tests__/plugin-contribution-types.test.ts b/packages/core/src/__tests__/plugin-contribution-types.test.ts index 83de1fd259..5e5352f456 100644 --- a/packages/core/src/__tests__/plugin-contribution-types.test.ts +++ b/packages/core/src/__tests__/plugin-contribution-types.test.ts @@ -11,7 +11,15 @@ import type { PluginSkillContribution, PluginWorkflowStepContribution, } from "../plugin-types.js"; -import { validatePluginManifest } from "../plugin-types.js"; +import type { + WorkflowExtensionContribution, + WorkflowExtensionFallback, + WorkflowExtensionKind, +} from "../workflow-extension-types.js"; +import { + validatePluginManifest, + validateWorkflowExtensionContribution, +} from "../plugin-types.js"; describe("plugin contribution type constraints", () => { it("accepts setup check result status variants", () => { @@ -153,6 +161,47 @@ describe("plugin contribution type constraints", () => { expectTypeOf(plugin.executorRuntimeEnv).toBeFunction(); }); + it("accepts workflow extension contribution shapes", () => { + const kinds: WorkflowExtensionKind[] = [ + "column-metadata", + "move-policy", + "work-engine", + "node-handler", + "verdict-provider", + "merge-fact-provider", + ]; + const fallback: WorkflowExtensionFallback = "degradeToDefault"; + const extensions: WorkflowExtensionContribution[] = kinds.map((kind) => ({ + extensionId: `${kind}-demo`, + name: `${kind} demo`, + kind, + schemaVersion: 1, + fallback, + } as WorkflowExtensionContribution)); + + const plugin: FusionPlugin = { + manifest: { id: "plugin-workflow-extensions", name: "Workflow Extensions", version: "1.0.0" }, + state: "installed", + hooks: {}, + workflowExtensions: extensions, + }; + + expect(plugin.workflowExtensions).toHaveLength(6); + expect(validateWorkflowExtensionContribution(extensions[0])).toEqual([]); + expect( + validateWorkflowExtensionContribution({ + extensionId: "bad", + name: "Bad", + kind: "move-policy", + schemaVersion: 99, + fallback: "guess" as WorkflowExtensionFallback, + }), + ).toEqual([ + "workflowExtensions[0].schemaVersion must be 1; got 99", + "workflowExtensions[0].fallback must be one of: degradeToDefault, parkNeedsAttention, failClosed", + ]); + }); + it("compile-time rejects invalid prompt surfaces", () => { const validSurface: PluginPromptSurface = "triage"; expect(validSurface).toBe("triage"); @@ -173,6 +222,7 @@ describe("validatePluginManifest contribution metadata scope", () => { version: "1.0.0", skills: [{ skillId: "browser-reader", name: "Browser Reader" }], workflowSteps: [{ stepId: "browser-check", name: "Browser Check", mode: "prompt" }], + workflowExtensions: [{ extensionId: "move-policy", name: "Move Policy", kind: "move-policy" }], promptSurfaces: ["executor-system", "heartbeat"], setup: { binaryName: "agent-browser", description: "Browser runtime", channel: "stable" }, }); @@ -187,6 +237,7 @@ describe("validatePluginManifest contribution metadata scope", () => { version: "1.0.0", skills: [{ skillId: "Bad Skill", name: "Bad" }], workflowSteps: [{ stepId: "bad-step", name: "Bad Step", mode: "oops" as "prompt" }], + workflowExtensions: [{ extensionId: "Bad Extension", name: "Bad", kind: "not-real" as WorkflowExtensionKind }], promptSurfaces: ["not-a-surface" as PluginPromptSurface], setup: { binaryName: "", description: "" }, }); diff --git a/packages/core/src/__tests__/plugin-loader-contributions.test.ts b/packages/core/src/__tests__/plugin-loader-contributions.test.ts index 701af0d52b..dd469bb96f 100644 --- a/packages/core/src/__tests__/plugin-loader-contributions.test.ts +++ b/packages/core/src/__tests__/plugin-loader-contributions.test.ts @@ -28,6 +28,7 @@ async function writePluginModule(dir: string, filename: string, plugin: FusionPl const hasContributionApis = "getPluginSkills" in PluginLoader.prototype && "getPluginWorkflowSteps" in PluginLoader.prototype && + "getPluginWorkflowExtensions" in PluginLoader.prototype && "getPluginPromptContributions" in PluginLoader.prototype && "getPluginSetupInfo" in PluginLoader.prototype; @@ -52,15 +53,23 @@ describe.skipIf(!hasContributionApis)("PluginLoader contribution loading", () => const pluginDir = join(rootDir, "plugins"); const alpha = makePlugin( - makeManifest({ id: "plugin-alpha", skills: [{ skillId: "alpha", name: "Alpha" }], workflowSteps: [{ stepId: "wf-alpha", name: "WF Alpha", mode: "prompt" }], promptSurfaces: ["triage"] }), + makeManifest({ + id: "plugin-alpha", + skills: [{ skillId: "alpha", name: "Alpha" }], + workflowSteps: [{ stepId: "wf-alpha", name: "WF Alpha", mode: "prompt" }], + workflowExtensions: [{ extensionId: "move-policy", name: "Move Policy", kind: "move-policy" }], + promptSurfaces: ["triage"], + }), ); alpha.skills = [{ skillId: "alpha", name: "Alpha", description: "alpha", enabled: false } as any]; alpha.workflowSteps = [{ stepId: "wf-alpha", name: "WF Alpha", description: "wf", mode: "prompt", prompt: "Run", enabled: false } as any]; + alpha.workflowExtensions = [{ extensionId: "move-policy", name: "Move Policy", kind: "move-policy", schemaVersion: 1, fallback: "degradeToDefault" } as any]; alpha.promptContributions = { enabledByDefault: false, contributions: [{ surface: "triage", content: "Alpha triage" }] }; const beta = makePlugin(makeManifest({ id: "plugin-beta" })); beta.skills = [{ skillId: "beta", name: "Beta", description: "beta", enabled: true } as any]; beta.workflowSteps = [{ stepId: "wf-beta", name: "WF Beta", description: "wf", mode: "script", scriptName: "test" } as any]; + beta.workflowExtensions = [{ extensionId: "work-engine", name: "Work Engine", kind: "work-engine", schemaVersion: 1, fallback: "parkNeedsAttention" } as any]; beta.promptContributions = { enabledByDefault: true, contributions: [{ surface: "reviewer", content: "Beta reviewer" }] }; const alphaPath = await writePluginModule(pluginDir, "alpha.mjs", alpha); @@ -72,10 +81,12 @@ describe.skipIf(!hasContributionApis)("PluginLoader contribution loading", () => const skills = loader.getPluginSkills(); const steps = loader.getPluginWorkflowSteps(); + const extensions = loader.getPluginWorkflowExtensions(); const prompts = loader.getPluginPromptContributions(); expect(skills.map((s) => s.pluginId).sort()).toEqual(["plugin-alpha", "plugin-beta"]); expect(steps.map((s) => s.pluginId).sort()).toEqual(["plugin-alpha", "plugin-beta"]); + expect(extensions.map((e) => e.pluginId).sort()).toEqual(["plugin-alpha", "plugin-beta"]); expect(prompts.map((p) => p.pluginId).sort()).toEqual(["plugin-alpha", "plugin-beta"]); expect(skills.some((s) => s.skill.enabled === false)).toBe(true); expect(steps.some((s) => s.step.enabled === false)).toBe(true); diff --git a/packages/core/src/__tests__/transition-parity.test.ts b/packages/core/src/__tests__/transition-parity.test.ts index 17bc033994..599ec2944e 100644 --- a/packages/core/src/__tests__/transition-parity.test.ts +++ b/packages/core/src/__tests__/transition-parity.test.ts @@ -21,6 +21,8 @@ import { TransitionRejectionError } from "../store.js"; import { resolveAllowedColumns, workflowHasColumn } from "../workflow-transitions.js"; import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; import { readTransitionPending } from "../transition-pending.js"; +import { WORKFLOW_EXTENSION_SCHEMA_VERSION } from "../workflow-extension-types.js"; +import { __resetWorkflowExtensionRegistryForTests, getWorkflowExtensionRegistry } from "../workflow-extension-registry.js"; import { createTaskStoreTestHarness } from "./store-test-helpers.js"; const ALL_COLUMNS: Column[] = ["triage", "todo", "in-progress", "in-review", "done", "archived"]; @@ -58,6 +60,7 @@ describe("transition-parity — store flag-ON scenarios", () => { await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); }); afterEach(async () => { + __resetWorkflowExtensionRegistryForTests(); await harness.afterEach(); }); @@ -135,6 +138,53 @@ describe("transition-parity — store flag-ON scenarios", () => { expect((caught as TransitionRejectionError).rejection.code).toBe("guard-rejected"); }); + it("move-policy extensions can veto structurally valid workflow moves", async () => { + getWorkflowExtensionRegistry().register("policy-plugin", { + extensionId: "review-lock", + name: "Review lock", + kind: "move-policy", + schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION, + fallback: "failClosed", + evaluate: ({ toColumn }) => { + if (toColumn === "in-review") { + return { allowed: false, reason: "review lane locked", message: "Review lane is locked" }; + } + return { allowed: true }; + }, + }); + + const task = await seedInColumn("in-progress"); + let caught: unknown; + try { + await store.moveTask(task.id, "in-review", { moveSource: "user", allowDirectInReviewMove: true }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(TransitionRejectionError); + expect((caught as TransitionRejectionError).rejection.messageKey).toBe("transition.rejected.workflowMovePolicy"); + expect((await store.getTask(task.id))?.column).toBe("in-progress"); + }); + + it("move-policy extensions receive actor and source context when allowing moves", async () => { + const seen: Array<{ actorKind?: string; source?: string }> = []; + getWorkflowExtensionRegistry().register("policy-plugin", { + extensionId: "context-capture", + name: "Context capture", + kind: "move-policy", + schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION, + fallback: "failClosed", + evaluate: ({ actor, source }) => { + seen.push({ actorKind: actor?.kind, source }); + return { allowed: true }; + }, + }); + + const task = await seedInColumn("triage"); + const moved = await store.moveTask(task.id, "todo", { moveSource: "user", workflowMoveSource: "board-drag" }); + expect(moved.column).toBe("todo"); + expect(seen).toEqual([{ actorKind: "human", source: "board-drag" }]); + }); + it("handoffToReview maps skipMergeBlocker onto bypassGuards and enqueues exactly once", async () => { const task = await seedInColumn("in-progress"); await store.handoffToReview(task.id, { diff --git a/packages/core/src/__tests__/workflow-extension-registry.test.ts b/packages/core/src/__tests__/workflow-extension-registry.test.ts new file mode 100644 index 0000000000..07079a5990 --- /dev/null +++ b/packages/core/src/__tests__/workflow-extension-registry.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { + WorkflowExtensionRegistry, + WorkflowExtensionRegistrationError, +} from "../workflow-extension-registry.js"; +import type { WorkflowExtensionContribution } from "../workflow-extension-types.js"; + +function extension(extensionId = "move-policy"): WorkflowExtensionContribution { + return { + extensionId, + name: "Move Policy", + kind: "move-policy", + schemaVersion: 1, + fallback: "degradeToDefault", + }; +} + +describe("WorkflowExtensionRegistry", () => { + it("registers and lists plugin-namespaced workflow extensions", () => { + const registry = new WorkflowExtensionRegistry(); + + const registered = registry.register("plugin-a", extension()); + + expect(registered.id).toBe("plugin:plugin-a:move-policy"); + expect(registry.get("plugin:plugin-a:move-policy")).toBe(registered); + expect(registry.list("move-policy")).toEqual([registered]); + expect(registry.list("work-engine")).toEqual([]); + }); + + it("rejects duplicate ids", () => { + const registry = new WorkflowExtensionRegistry(); + registry.register("plugin-a", extension()); + + expect(() => registry.register("plugin-a", extension())).toThrow(WorkflowExtensionRegistrationError); + }); + + it("unregisters all extensions for a plugin", () => { + const registry = new WorkflowExtensionRegistry(); + registry.register("plugin-a", extension("move-policy")); + registry.register("plugin-a", extension("work-engine")); + registry.register("plugin-b", extension("move-policy")); + + expect(registry.unregisterPlugin("plugin-a")).toEqual([ + "plugin:plugin-a:move-policy", + "plugin:plugin-a:work-engine", + ]); + expect(registry.list()).toHaveLength(1); + }); + + it("marks extensions degraded without removing definitions", () => { + const registry = new WorkflowExtensionRegistry(); + registry.register("plugin-a", extension()); + + expect(registry.degrade(["plugin:plugin-a:move-policy"], "force-disabled", "disabled")).toEqual([ + "plugin:plugin-a:move-policy", + ]); + expect(registry.get("plugin:plugin-a:move-policy")?.degraded).toEqual({ + reason: "force-disabled", + message: "disabled", + }); + }); +}); diff --git a/packages/core/src/__tests__/workflow-ir-extension-metadata.test.ts b/packages/core/src/__tests__/workflow-ir-extension-metadata.test.ts new file mode 100644 index 0000000000..7dff28685f --- /dev/null +++ b/packages/core/src/__tests__/workflow-ir-extension-metadata.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { + downgradeIrToV1IfPure, + parseWorkflowIr, +} from "../workflow-ir.js"; +import type { WorkflowIrV2 } from "../workflow-ir-types.js"; + +function ir(overrides: Partial = {}): WorkflowIrV2 { + return { + version: "v2", + name: "extensions", + columns: [{ id: "todo", name: "todo", traits: [] }], + nodes: [ + { id: "start", kind: "start", column: "todo" }, + { id: "end", kind: "end", column: "todo" }, + ], + edges: [{ from: "start", to: "end" }], + ...overrides, + }; +} + +describe("workflow IR extension metadata", () => { + it("accepts plugin-namespaced column and node extension metadata", () => { + const parsed = parseWorkflowIr(ir({ + columns: [ + { + id: "todo", + name: "todo", + traits: [], + extensions: { + "plugin:workflow-pack:role": { role: "lead" }, + }, + }, + ], + nodes: [ + { + id: "start", + kind: "start", + column: "todo", + extensions: { + "plugin:workflow-pack:node-handler": { handler: "plan" }, + }, + }, + { id: "end", kind: "end", column: "todo" }, + ], + })); + + expect(parsed.version).toBe("v2"); + if (parsed.version !== "v2") throw new Error("expected v2"); + expect(parsed.columns[0].extensions?.["plugin:workflow-pack:role"]).toEqual({ role: "lead" }); + expect(parsed.nodes[0].extensions?.["plugin:workflow-pack:node-handler"]).toEqual({ handler: "plan" }); + }); + + it("rejects extension metadata keys outside the plugin namespace", () => { + expect(() => + parseWorkflowIr(ir({ + columns: [ + { + id: "todo", + name: "todo", + traits: [], + extensions: { role: { role: "lead" } }, + }, + ], + })), + ).toThrow(/must be plugin-namespaced/); + }); + + it("rejects non-object extension metadata values", () => { + expect(() => + parseWorkflowIr(ir({ + nodes: [ + { + id: "start", + kind: "start", + column: "todo", + extensions: { "plugin:workflow-pack:node-handler": "plan" as never }, + }, + { id: "end", kind: "end", column: "todo" }, + ], + })), + ).toThrow(/metadata must be an object/); + }); + + it("keeps v2 when otherwise-pure workflows carry extension metadata", () => { + const parsed = parseWorkflowIr(ir({ + columns: [ + { + id: "triage", + name: "triage", + traits: [], + extensions: { "plugin:workflow-pack:role": { role: "lead" } }, + }, + { id: "todo", name: "todo", traits: [] }, + { id: "in-progress", name: "in-progress", traits: [] }, + { id: "in-review", name: "in-review", traits: [] }, + { id: "done", name: "done", traits: [] }, + { id: "archived", name: "archived", traits: [] }, + ], + })); + + expect(downgradeIrToV1IfPure(parsed).version).toBe("v2"); + }); +}); diff --git a/packages/core/src/board-action-services.ts b/packages/core/src/board-action-services.ts new file mode 100644 index 0000000000..d52b86d04f --- /dev/null +++ b/packages/core/src/board-action-services.ts @@ -0,0 +1,34 @@ +import type { ColumnId, Task } from "./types.js"; + +export interface BoardActionTaskStore { + moveTask(id: string, column: ColumnId, options?: { preserveProgress?: boolean; moveSource?: "user" | "engine" | "scheduler" }): Promise; + updateTask(id: string, updates: Record): Promise; +} + +export interface MoveBoardTaskInput { + taskId: string; + column: ColumnId; + preserveProgress?: boolean; + source?: "user" | "engine" | "scheduler"; +} + +export interface UpdateBoardTaskInput { + taskId: string; + updates: Record; +} + +export function createBoardActionServices(store: BoardActionTaskStore) { + return { + moveTask(input: MoveBoardTaskInput): Promise { + return store.moveTask(input.taskId, input.column, { + preserveProgress: input.preserveProgress, + moveSource: input.source ?? "user", + }); + }, + updateTask(input: UpdateBoardTaskInput): Promise { + return store.updateTask(input.taskId, input.updates); + }, + }; +} + +export type BoardActionServices = ReturnType; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 90802a24c8..f8aaba2f79 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -856,12 +856,68 @@ export type { export { validatePluginManifest, validatePluginTraitContribution, + validateWorkflowExtensionContribution, PLUGIN_TRAIT_RESTRICTED_FLAGS, PLUGIN_TRAIT_ALLOWED_HOOK_POINTS, PLUGIN_TRAIT_SCHEMA_VERSION, normalizePluginUiContributionSurface, normalizePluginUiContributionDefinition, } from "./plugin-types.js"; +export type { + WorkflowExtensionContribution, + WorkflowExtensionMetadata, + WorkflowExtensionBaseContribution, + WorkflowColumnMetadataExtensionContribution, + WorkflowMovePolicyExtensionContribution, + WorkflowWorkEngineExtensionContribution, + WorkflowNodeHandlerExtensionContribution, + TaskVerdictProviderExtensionContribution, + AutoMergeFactProviderExtensionContribution, + WorkflowExtensionConfigField, + WorkflowExtensionConfigSchema, + WorkflowExtensionFallback, + WorkflowExtensionKind, + WorkflowMovePolicyDecision, + WorkflowMovePolicyInput, + WorkflowMovePolicyHandler, + WorkflowWorkEngineDispatchResult, + WorkflowWorkEngineInput, + WorkflowWorkEngineHandler, + WorkflowNodeExtensionResult, + WorkflowNodeHandlerInput, + WorkflowNodeExtensionHandler, + TaskVerdictStatus, + TaskVerdictProviderInput, + TaskVerdictProviderResult, + TaskVerdictProviderHandler, + AutoMergeRoute, + AutoMergeFactProviderInput, + AutoMergeFactProviderResult, + AutoMergeFactProviderHandler, +} from "./workflow-extension-types.js"; +export { + WORKFLOW_EXTENSION_SCHEMA_VERSION, + workflowExtensionRegistryId, +} from "./workflow-extension-types.js"; +export { + WorkflowExtensionRegistry, + WorkflowExtensionRegistrationError, + getWorkflowExtensionRegistry, + __resetWorkflowExtensionRegistryForTests, +} from "./workflow-extension-registry.js"; +export type { + WorkflowExtensionDefinition, + WorkflowExtensionRegistrationReason, +} from "./workflow-extension-registry.js"; +export { + createBoardActionServices, +} from "./board-action-services.js"; +export type { + BoardActionServices, + BoardActionTaskStore, + MoveBoardTaskInput, + UpdateBoardTaskInput, +} from "./board-action-services.js"; export { PluginStore } from "./plugin-store.js"; export type { PluginStoreEvents, PluginRegistrationInput, PluginUpdateInput } from "./plugin-store.js"; export { PluginLoader, resolvePluginEntryPath } from "./plugin-loader.js"; diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index 098635f848..d6f1f5adb1 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -40,6 +40,7 @@ import type { PluginSetupHooks, PluginSetupCheckResult, } from "./plugin-types.js"; +import type { WorkflowExtensionContribution } from "./workflow-extension-types.js"; import { normalizePluginUiContributionDefinition, validatePluginManifest } from "./plugin-types.js"; import { createLogger } from "./logger.js"; import { getCreateAiSessionFactory, getCreateInteractiveAiSessionFactory } from "./ai-engine-loader.js"; @@ -1067,6 +1068,21 @@ export class PluginLoader extends EventEmitter<{ return steps; } + /** + * Get all workflow extension contributions from loaded plugins. + */ + getPluginWorkflowExtensions(): Array<{ pluginId: string; extension: WorkflowExtensionContribution }> { + const extensions: Array<{ pluginId: string; extension: WorkflowExtensionContribution }> = []; + for (const [pluginId, plugin] of this.plugins) { + if (plugin.workflowExtensions) { + for (const extension of plugin.workflowExtensions) { + extensions.push({ pluginId, extension }); + } + } + } + return extensions; + } + /** * Get all trait contributions from loaded plugins (U8). */ diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts index 4158bb57b1..6d438ad385 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -14,6 +14,15 @@ import type { Database } from "./db.js"; import type { TaskStore } from "./store.js"; import type { PlanningQuestion, Task, WorkflowStepMode, WorkflowStepToolMode } from "./types.js"; +import type { + WorkflowExtensionContribution, + WorkflowExtensionFallback, + WorkflowExtensionKind, + WorkflowExtensionMetadata, +} from "./workflow-extension-types.js"; +import { + WORKFLOW_EXTENSION_SCHEMA_VERSION, +} from "./workflow-extension-types.js"; const SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; const PROMPT_CONTRIBUTION_SURFACES = ["executor-system", "executor-task", "triage", "reviewer", "heartbeat"] as const; @@ -51,6 +60,8 @@ export interface PluginManifest { workflowSteps?: Array<{ stepId: string; name: string }>; /** Optional trait metadata used for discovery UIs (U8). */ traits?: Array<{ traitId: string; name: string }>; + /** Optional workflow extension metadata used for discovery UIs. */ + workflowExtensions?: WorkflowExtensionMetadata[]; /** Prompt surfaces this plugin contributes to. */ promptSurfaces?: PluginPromptSurface[]; /** Setup metadata for plugin-managed binaries/runtimes. */ @@ -900,6 +911,83 @@ export function validatePluginTraitContribution( return errors; } +const WORKFLOW_EXTENSION_KINDS: ReadonlySet = new Set([ + "column-metadata", + "move-policy", + "work-engine", + "node-handler", + "verdict-provider", + "merge-fact-provider", +]); + +const WORKFLOW_EXTENSION_FALLBACKS: ReadonlySet = new Set([ + "degradeToDefault", + "parkNeedsAttention", + "failClosed", +]); + +/** + * Validate one full plugin workflow extension contribution. Discovery metadata + * (`{ extensionId, name, kind }`) is validated in validatePluginManifest; runtime + * contribution objects use this stricter check. + */ +export function validateWorkflowExtensionContribution( + extension: unknown, + index = 0, +): string[] { + const errors: string[] = []; + const prefix = `workflowExtensions[${index}]`; + if (!extension || typeof extension !== "object" || Array.isArray(extension)) { + return [`${prefix} must be an object`]; + } + const e = extension as Record; + + if (!e.extensionId || typeof e.extensionId !== "string" || e.extensionId.trim() === "") { + errors.push(`${prefix}.extensionId is required and must be a non-empty string`); + } else if (!SLUG_PATTERN.test(e.extensionId)) { + errors.push( + `${prefix}.extensionId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)`, + ); + } + + if (!e.name || typeof e.name !== "string" || e.name.trim() === "") { + errors.push(`${prefix}.name is required and must be a non-empty string`); + } + + if (typeof e.kind !== "string" || !WORKFLOW_EXTENSION_KINDS.has(e.kind as WorkflowExtensionKind)) { + errors.push( + `${prefix}.kind must be one of: ${[...WORKFLOW_EXTENSION_KINDS].join(", ")}`, + ); + } + + if (e.schemaVersion === undefined) { + errors.push(`${prefix}.schemaVersion is required`); + } else if (e.schemaVersion !== WORKFLOW_EXTENSION_SCHEMA_VERSION) { + errors.push( + `${prefix}.schemaVersion must be ${WORKFLOW_EXTENSION_SCHEMA_VERSION}; got ${String(e.schemaVersion)}`, + ); + } + + if (typeof e.fallback !== "string" || !WORKFLOW_EXTENSION_FALLBACKS.has(e.fallback as WorkflowExtensionFallback)) { + errors.push( + `${prefix}.fallback must be one of: ${[...WORKFLOW_EXTENSION_FALLBACKS].join(", ")}`, + ); + } + + if (e.configSchema !== undefined) { + if (typeof e.configSchema !== "object" || e.configSchema === null || Array.isArray(e.configSchema)) { + errors.push(`${prefix}.configSchema must be an object`); + } else { + const fields = (e.configSchema as { fields?: unknown }).fields; + if (!Array.isArray(fields)) { + errors.push(`${prefix}.configSchema.fields must be an array`); + } + } + } + + return errors; +} + /** * Prompt injection surfaces for plugin-contributed instructions. * - executor-system: Appended to executor agent system prompt @@ -1039,6 +1127,8 @@ export interface FusionPlugin { workflowSteps?: PluginWorkflowStepContribution[]; /** Plugin-contributed column traits (U8). */ traits?: PluginTraitContribution[]; + /** Plugin-contributed workflow extension points. */ + workflowExtensions?: WorkflowExtensionContribution[]; /** Plugin-contributed prompt injections. */ promptContributions?: PluginPromptContributions; /** Plugin-managed setup metadata and lifecycle hooks. */ @@ -1266,6 +1356,42 @@ export function validatePluginManifest(manifest: unknown): { valid: boolean; err } } + // Optional: workflow extension contributions. Full contribution shapes validate + // through validateWorkflowExtensionContribution; discovery metadata uses the + // lighter {extensionId, name, kind} form. + if (m.workflowExtensions !== undefined) { + if (!Array.isArray(m.workflowExtensions)) { + errors.push("workflowExtensions must be an array"); + } else { + for (const [index, extension] of m.workflowExtensions.entries()) { + if (!extension || typeof extension !== "object") { + errors.push(`workflowExtensions[${index}] must be an object`); + continue; + } + const extensionMeta = extension as Record; + if ( + extensionMeta.schemaVersion !== undefined || + extensionMeta.fallback !== undefined || + extensionMeta.configSchema !== undefined + ) { + errors.push(...validateWorkflowExtensionContribution(extensionMeta, index)); + continue; + } + if (!extensionMeta.extensionId || typeof extensionMeta.extensionId !== "string" || extensionMeta.extensionId.trim() === "") { + errors.push(`workflowExtensions[${index}].extensionId is required and must be a non-empty string`); + } else if (!SLUG_PATTERN.test(extensionMeta.extensionId)) { + errors.push(`workflowExtensions[${index}].extensionId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)`); + } + if (!extensionMeta.name || typeof extensionMeta.name !== "string" || extensionMeta.name.trim() === "") { + errors.push(`workflowExtensions[${index}].name is required and must be a non-empty string`); + } + if (typeof extensionMeta.kind !== "string" || !WORKFLOW_EXTENSION_KINDS.has(extensionMeta.kind as WorkflowExtensionKind)) { + errors.push(`workflowExtensions[${index}].kind must be one of: ${[...WORKFLOW_EXTENSION_KINDS].join(", ")}`); + } + } + } + } + // Optional: prompt surface metadata if (m.promptSurfaces !== undefined) { if (!Array.isArray(m.promptSurfaces)) { @@ -1346,4 +1472,3 @@ export function validatePluginManifest(manifest: unknown): { valid: boolean; err errors, }; } - diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 4d568f33e3..855ffb988a 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -54,6 +54,8 @@ import { } from "./transition-pending.js"; import { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js"; import type { WorkflowIr, WorkflowIrColumn, WorkflowFieldDefinition, WorkflowSettingDefinition } from "./workflow-ir-types.js"; +import { getWorkflowExtensionRegistry } from "./workflow-extension-registry.js"; +import type { WorkflowMovePolicyInput } from "./workflow-extension-types.js"; import { validateCustomFieldPatch, applyFieldDefaults, @@ -1182,6 +1184,9 @@ interface MoveTaskOptions { preserveStatus?: boolean; allocateWorktree?: (reservedNames: Set) => string | null; moveSource?: "user" | "engine" | "scheduler"; + workflowMoveActor?: WorkflowMovePolicyInput["actor"]; + workflowMoveSource?: string; + workflowMoveMetadata?: Record; skipMergeBlocker?: boolean; allowDirectInReviewMove?: boolean; /** @@ -6327,6 +6332,64 @@ export class TaskStore extends EventEmitter { }); } + private resolveWorkflowMoveActor( + moveSource: NonNullable, + internal: MoveTaskInternalOptions, + options?: MoveTaskOptions, + ): WorkflowMovePolicyInput["actor"] { + if (options?.workflowMoveActor) return options.workflowMoveActor; + if (moveSource === "user") return { kind: "human" }; + if (moveSource === "scheduler") return { kind: "system" }; + if (internal.runContext?.agentId) { + return { kind: "agent", id: internal.runContext.agentId }; + } + return { kind: "engine" }; + } + + private async evaluateWorkflowMovePolicies(input: WorkflowMovePolicyInput): Promise { + const policies = getWorkflowExtensionRegistry().list("move-policy"); + for (const definition of policies) { + const extension = definition.extension; + if (definition.degraded || extension.kind !== "move-policy" || !extension.evaluate) continue; + + let decision: Awaited>>; + try { + decision = await extension.evaluate(input); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + storeLog.warn("Workflow move-policy extension faulted", { + phase: "moveTaskInternal:move-policy", + taskId: input.task.id, + extensionId: definition.id, + fallback: extension.fallback, + error: message, + }); + if (extension.fallback === "degradeToDefault") continue; + throw new TransitionRejectionError( + makeTransitionRejection( + "guard-rejected", + "transition.rejected.workflowMovePolicy", + extension.fallback === "parkNeedsAttention", + `Move policy '${definition.id}' failed: ${message}`, + ), + `Cannot move ${input.task.id} to '${input.toColumn}': move policy '${definition.id}' failed`, + ); + } + + if (!decision.allowed) { + throw new TransitionRejectionError( + makeTransitionRejection( + "guard-rejected", + "transition.rejected.workflowMovePolicy", + true, + decision.reason, + ), + decision.message, + ); + } + } + } + private async moveTaskInternal( id: string, toColumn: ColumnId, @@ -6463,6 +6526,15 @@ export class TaskStore extends EventEmitter { `Valid targets: ${allowed.join(", ") || "none"}`, ); } + await this.evaluateWorkflowMovePolicies({ + task, + workflow: workflowIr, + fromColumn, + toColumn, + actor: this.resolveWorkflowMoveActor(moveSource, internal, options), + source: options?.workflowMoveSource ?? moveSource, + metadata: options?.workflowMoveMetadata, + }); // 3. Sync trait guards (in-lock). Skipped entirely when bypassGuards // (engine/recovery moves, KTD-9). The default workflow's merge-blocker // trait reads the same getTaskMergeBlocker. diff --git a/packages/core/src/workflow-extension-registry.ts b/packages/core/src/workflow-extension-registry.ts new file mode 100644 index 0000000000..d0fba1909d --- /dev/null +++ b/packages/core/src/workflow-extension-registry.ts @@ -0,0 +1,111 @@ +import type { + WorkflowExtensionContribution, + WorkflowExtensionKind, +} from "./workflow-extension-types.js"; +import { workflowExtensionRegistryId } from "./workflow-extension-types.js"; + +export type WorkflowExtensionRegistrationReason = + | "duplicate-id" + | "invalid-plugin-id" + | "invalid-extension-id"; + +export class WorkflowExtensionRegistrationError extends Error { + constructor( + public readonly reason: WorkflowExtensionRegistrationReason, + message: string, + ) { + super(message); + this.name = "WorkflowExtensionRegistrationError"; + } +} + +export interface WorkflowExtensionDefinition { + id: string; + pluginId: string; + extension: WorkflowExtensionContribution; + degraded?: { + reason: "force-disabled" | "plugin-unloaded"; + message: string; + }; +} + +type WorkflowExtensionDegradeReason = NonNullable["reason"]; + +const PLUGIN_ID_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; + +export class WorkflowExtensionRegistry { + private definitions = new Map(); + + register(pluginId: string, extension: WorkflowExtensionContribution): WorkflowExtensionDefinition { + if (!PLUGIN_ID_PATTERN.test(pluginId)) { + throw new WorkflowExtensionRegistrationError( + "invalid-plugin-id", + `Plugin id '${pluginId}' is not a valid workflow extension namespace`, + ); + } + if (!PLUGIN_ID_PATTERN.test(extension.extensionId)) { + throw new WorkflowExtensionRegistrationError( + "invalid-extension-id", + `Workflow extension id '${extension.extensionId}' is not a valid slug`, + ); + } + const id = workflowExtensionRegistryId(pluginId, extension.extensionId); + if (this.definitions.has(id)) { + throw new WorkflowExtensionRegistrationError( + "duplicate-id", + `Workflow extension '${id}' is already registered`, + ); + } + const definition = { id, pluginId, extension }; + this.definitions.set(id, definition); + return definition; + } + + unregister(id: string): boolean { + return this.definitions.delete(id); + } + + unregisterPlugin(pluginId: string): string[] { + const removed: string[] = []; + for (const [id, definition] of this.definitions) { + if (definition.pluginId !== pluginId) continue; + this.definitions.delete(id); + removed.push(id); + } + return removed; + } + + degrade(ids: readonly string[], reason: WorkflowExtensionDegradeReason, message: string): string[] { + const degraded: string[] = []; + for (const id of ids) { + const definition = this.definitions.get(id); + if (!definition) continue; + definition.degraded = { reason, message }; + degraded.push(id); + } + return degraded; + } + + get(id: string): WorkflowExtensionDefinition | undefined { + return this.definitions.get(id); + } + + list(kind?: WorkflowExtensionKind): WorkflowExtensionDefinition[] { + const definitions = [...this.definitions.values()]; + return kind ? definitions.filter((definition) => definition.extension.kind === kind) : definitions; + } + + clear(): void { + this.definitions.clear(); + } +} + +const defaultWorkflowExtensionRegistry = new WorkflowExtensionRegistry(); + +export function getWorkflowExtensionRegistry(): WorkflowExtensionRegistry { + return defaultWorkflowExtensionRegistry; +} + +export function __resetWorkflowExtensionRegistryForTests(): void { + defaultWorkflowExtensionRegistry.clear(); +} diff --git a/packages/core/src/workflow-extension-types.ts b/packages/core/src/workflow-extension-types.ts new file mode 100644 index 0000000000..2bdc06f8ac --- /dev/null +++ b/packages/core/src/workflow-extension-types.ts @@ -0,0 +1,178 @@ +import type { Task, TaskDetail } from "./types.js"; +import type { WorkflowIr, WorkflowIrNode } from "./workflow-ir-types.js"; + +export const WORKFLOW_EXTENSION_SCHEMA_VERSION = 1 as const; + +export type WorkflowExtensionFallback = "degradeToDefault" | "parkNeedsAttention" | "failClosed"; + +export type WorkflowExtensionKind = + | "column-metadata" + | "move-policy" + | "work-engine" + | "node-handler" + | "verdict-provider" + | "merge-fact-provider"; + +export interface WorkflowExtensionBaseContribution { + extensionId: string; + name: string; + description?: string; + schemaVersion: typeof WORKFLOW_EXTENSION_SCHEMA_VERSION; + fallback: WorkflowExtensionFallback; +} + +export interface WorkflowExtensionConfigField { + key: string; + type: "string" | "number" | "boolean" | "enum" | "object" | "array"; + required?: boolean; + enumValues?: readonly string[]; + description?: string; +} + +export interface WorkflowExtensionConfigSchema { + fields: WorkflowExtensionConfigField[]; +} + +export interface WorkflowColumnMetadataExtensionContribution extends WorkflowExtensionBaseContribution { + kind: "column-metadata"; + configSchema?: WorkflowExtensionConfigSchema; +} + +export type WorkflowMovePolicyDecision = + | { allowed: true; reason?: string } + | { allowed: false; reason: string; message: string }; + +export interface WorkflowMovePolicyInput { + task: Task; + workflow: WorkflowIr; + fromColumn: string; + toColumn: string; + actor?: { + kind: "human" | "agent" | "engine" | "system"; + id?: string; + }; + source?: string; + metadata?: Record; +} + +export type WorkflowMovePolicyHandler = + (input: WorkflowMovePolicyInput) => Promise | WorkflowMovePolicyDecision; + +export interface WorkflowMovePolicyExtensionContribution extends WorkflowExtensionBaseContribution { + kind: "move-policy"; + evaluate?: WorkflowMovePolicyHandler; + configSchema?: WorkflowExtensionConfigSchema; +} + +export type WorkflowWorkEngineDispatchResult = + | { kind: "not-claimed" } + | { kind: "claimed"; runId?: string; message?: string } + | { kind: "degraded-to-default"; reason: string } + | { kind: "parked"; reason: string; message: string }; + +export interface WorkflowWorkEngineInput { + task: TaskDetail; + workflow: WorkflowIr; + columnId: string; + metadata?: Record; + signal?: AbortSignal; +} + +export type WorkflowWorkEngineHandler = + (input: WorkflowWorkEngineInput) => Promise; + +export interface WorkflowWorkEngineExtensionContribution extends WorkflowExtensionBaseContribution { + kind: "work-engine"; + dispatch?: WorkflowWorkEngineHandler; + configSchema?: WorkflowExtensionConfigSchema; +} + +export type WorkflowNodeExtensionResult = + | { outcome: "success" | "failure"; value?: string; contextPatch?: Record } + | { outcome: `outcome:${string}`; value?: string; contextPatch?: Record }; + +export interface WorkflowNodeHandlerInput { + task: TaskDetail; + workflow: WorkflowIr; + node: WorkflowIrNode; + context: Record; + signal?: AbortSignal; +} + +export type WorkflowNodeExtensionHandler = + (input: WorkflowNodeHandlerInput) => Promise; + +export interface WorkflowNodeHandlerExtensionContribution extends WorkflowExtensionBaseContribution { + kind: "node-handler"; + nodeKind?: string; + handle?: WorkflowNodeExtensionHandler; + configSchema?: WorkflowExtensionConfigSchema; +} + +export type TaskVerdictStatus = "pass" | "fail" | "blocked" | "error" | "pending"; + +export interface TaskVerdictProviderInput { + task: TaskDetail; + workflow: WorkflowIr; + reworkRound: number; + metadata?: Record; + signal?: AbortSignal; +} + +export interface TaskVerdictProviderResult { + status: Exclude; + summary: string; + failureReasons?: Array<{ code: string; message: string }>; + writerId?: string; +} + +export type TaskVerdictProviderHandler = + (input: TaskVerdictProviderInput) => Promise; + +export interface TaskVerdictProviderExtensionContribution extends WorkflowExtensionBaseContribution { + kind: "verdict-provider"; + evaluate?: TaskVerdictProviderHandler; + configSchema?: WorkflowExtensionConfigSchema; +} + +export type AutoMergeRoute = "auto-enqueue" | "workflow-subgraph" | "manual-required" | "blocked"; + +export interface AutoMergeFactProviderInput { + task: TaskDetail; + workflow: WorkflowIr; + metadata?: Record; +} + +export interface AutoMergeFactProviderResult { + route?: AutoMergeRoute; + facts?: Record; + reason?: string; +} + +export type AutoMergeFactProviderHandler = + (input: AutoMergeFactProviderInput) => Promise | AutoMergeFactProviderResult; + +export interface AutoMergeFactProviderExtensionContribution extends WorkflowExtensionBaseContribution { + kind: "merge-fact-provider"; + collect?: AutoMergeFactProviderHandler; + configSchema?: WorkflowExtensionConfigSchema; +} + +export type WorkflowExtensionContribution = + | WorkflowColumnMetadataExtensionContribution + | WorkflowMovePolicyExtensionContribution + | WorkflowWorkEngineExtensionContribution + | WorkflowNodeHandlerExtensionContribution + | TaskVerdictProviderExtensionContribution + | AutoMergeFactProviderExtensionContribution; + +export interface WorkflowExtensionMetadata { + extensionId: string; + name: string; + kind: WorkflowExtensionKind; + description?: string; +} + +export function workflowExtensionRegistryId(pluginId: string, extensionId: string): string { + return `plugin:${pluginId}:${extensionId}`; +} diff --git a/packages/core/src/workflow-ir-types.ts b/packages/core/src/workflow-ir-types.ts index 16e5d9839f..5b06f7cd43 100644 --- a/packages/core/src/workflow-ir-types.ts +++ b/packages/core/src/workflow-ir-types.ts @@ -28,6 +28,8 @@ export interface WorkflowIrNode { kind: WorkflowIrNodeKind; /** v2: the column this node is placed in. Must reference a defined column id. */ column?: string; + /** Plugin-namespaced extension metadata keyed as `plugin::`. */ + extensions?: Record>; config?: Record; } @@ -238,6 +240,8 @@ export interface WorkflowIrColumn { id: string; name: string; traits: WorkflowIrColumnTrait[]; + /** Plugin-namespaced extension metadata keyed as `plugin::`. */ + extensions?: Record>; /** Optional permanent-agent binding (column-agent plan KTD-1). Additive and * omitted entirely when unset — never serialized as `agent: null` — so legacy * and default workflows stay byte-identical (R9). */ diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index 9974abfd09..7812b5d481 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -13,6 +13,8 @@ import type { WorkflowSettingDefinition, WorkflowSettingType, } from "./workflow-ir-types.js"; +import { getWorkflowExtensionRegistry } from "./workflow-extension-registry.js"; +import type { WorkflowExtensionConfigField } from "./workflow-extension-types.js"; export class WorkflowIrError extends Error { constructor(message: string) { @@ -93,6 +95,7 @@ const MAX_REWORK_CYCLES_CAP = 10; /** Parallel concurrency bounds (KTD-3): range 1..8. */ const MAX_FOREACH_CONCURRENCY = 8; +const WORKFLOW_EXTENSION_KEY_PATTERN = /^plugin:[a-z0-9]([a-z0-9-]*[a-z0-9])?:[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; /** The implicit step-source artifact allowed when no artifacts are declared. */ const IMPLICIT_DEFAULT_ARTIFACT = "PROMPT.md"; @@ -909,10 +912,84 @@ function validateColumns(ir: WorkflowIrV2): void { if (!Array.isArray(column.traits)) { throw new WorkflowIrError(`Workflow IR column '${column.id}' traits must be an array`); } + validateExtensionMetadata(`Workflow IR column '${column.id}'`, column.extensions); validateColumnAgent(column); } } +function validateExtensionMetadata(owner: string, extensions: unknown): void { + if (extensions === undefined) return; + if (!extensions || typeof extensions !== "object" || Array.isArray(extensions)) { + throw new WorkflowIrError(`${owner} extensions must be an object`); + } + for (const [key, value] of Object.entries(extensions as Record)) { + if (!WORKFLOW_EXTENSION_KEY_PATTERN.test(key)) { + throw new WorkflowIrError( + `${owner} extension key '${key}' must be plugin-namespaced as plugin::`, + ); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new WorkflowIrError(`${owner} extension '${key}' metadata must be an object`); + } + validateRegisteredExtensionMetadata(owner, key, value as Record); + } +} + +function validateRegisteredExtensionMetadata( + owner: string, + key: string, + value: Record, +): void { + const definition = getWorkflowExtensionRegistry().get(key); + const fields = definition?.extension.configSchema?.fields; + if (!fields || fields.length === 0) return; + for (const field of fields) { + if (field.required && !(field.key in value)) { + throw new WorkflowIrError(`${owner} extension '${key}' missing required field '${field.key}'`); + } + if (field.key in value) { + validateExtensionFieldValue(owner, key, field, value[field.key]); + } + } +} + +function validateExtensionFieldValue( + owner: string, + key: string, + field: WorkflowExtensionConfigField, + value: unknown, +): void { + if (value === undefined) return; + const fail = (): never => { + throw new WorkflowIrError( + `${owner} extension '${key}' field '${field.key}' must be ${field.type}`, + ); + }; + if (field.type === "array") { + if (!Array.isArray(value)) fail(); + return; + } + if (field.type === "object") { + if (!value || typeof value !== "object" || Array.isArray(value)) fail(); + return; + } + if (field.type === "enum") { + if (typeof value !== "string") { + throw new WorkflowIrError( + `${owner} extension '${key}' field '${field.key}' must be ${field.type}`, + ); + } + const enumValue: string = value; + if (field.enumValues && !field.enumValues.includes(enumValue)) { + throw new WorkflowIrError( + `${owner} extension '${key}' field '${field.key}' must be one of: ${field.enumValues.join(", ")}`, + ); + } + return; + } + if (typeof value !== field.type) fail(); +} + /** Validate a column's optional permanent-agent binding (column-agent plan KTD-1). * Mirrors the `validateFields` early-return shape: absent → no-op; present → * `agentId` must be a non-empty string and `mode` exactly `defer`/`override`. @@ -942,6 +1019,7 @@ function validateV2(ir: WorkflowIrV2): void { const nodesById = new Map(ir.nodes.map((n) => [n.id, n])); for (const node of ir.nodes) { + validateExtensionMetadata(`Workflow node '${node.id}'`, node.extensions); if (node.column !== undefined && !columnIds.has(node.column)) { throw new WorkflowIrError( `Workflow node '${node.id}' references undefined column '${node.column}'`, @@ -1091,12 +1169,14 @@ export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr { // A permanent-agent binding is a v2-only feature (column-agent plan, R9): a // graph that staffs a column can never round-trip through a pre-v2 binary. if (col.agent !== undefined) return ir; + if (col.extensions !== undefined && Object.keys(col.extensions).length > 0) return ir; } // Every node must sit in its default seam-derived column. A node placed // elsewhere is a v2 feature (custom placement) and must stay v2. for (const node of ir.nodes) { if (node.column !== defaultColumnForNode(node)) return ir; + if (node.extensions !== undefined && Object.keys(node.extensions).length > 0) return ir; } // Pure v1: emit the v1 shape, dropping the synthesized `column` fields so the diff --git a/packages/engine/src/__tests__/auto-merge-fact-providers.test.ts b/packages/engine/src/__tests__/auto-merge-fact-providers.test.ts new file mode 100644 index 0000000000..c70eac43de --- /dev/null +++ b/packages/engine/src/__tests__/auto-merge-fact-providers.test.ts @@ -0,0 +1,59 @@ +// @vitest-environment node + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + WORKFLOW_EXTENSION_SCHEMA_VERSION, + __resetWorkflowExtensionRegistryForTests, + getWorkflowExtensionRegistry, + type TaskDetail, + type WorkflowIr, +} from "@fusion/core"; +import { evaluateAutoMergeFactProviders } from "../auto-merge-fact-providers.js"; + +describe("auto-merge fact providers", () => { + afterEach(() => { + __resetWorkflowExtensionRegistryForTests(); + }); + + it("collects facts and chooses the strictest route", async () => { + const workflow: WorkflowIr = { version: "v2", name: "w", columns: [], nodes: [], edges: [] }; + const task = { id: "FN-MERGE" } as TaskDetail; + const store = { + getTaskWorkflowSelection: vi.fn().mockReturnValue({ workflowId: "custom-workflow", stepIds: [] }), + getWorkflowDefinition: vi.fn().mockResolvedValue({ ir: workflow }), + }; + getWorkflowExtensionRegistry().register("merge-plugin", { + extensionId: "facts", + name: "Facts", + kind: "merge-fact-provider", + schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION, + fallback: "failClosed", + collect: vi.fn().mockResolvedValue({ + route: "manual-required", + facts: { needsOwner: true }, + reason: "owner approval required", + }), + }); + getWorkflowExtensionRegistry().register("merge-plugin", { + extensionId: "blocker", + name: "Blocker", + kind: "merge-fact-provider", + schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION, + fallback: "failClosed", + collect: vi.fn().mockResolvedValue({ + route: "blocked", + facts: { risk: "high" }, + reason: "risk gate blocked", + }), + }); + + await expect(evaluateAutoMergeFactProviders(store, task)).resolves.toEqual({ + route: "blocked", + facts: { + "plugin:merge-plugin:facts": { needsOwner: true }, + "plugin:merge-plugin:blocker": { risk: "high" }, + }, + reasons: ["owner approval required", "risk gate blocked"], + }); + }); +}); diff --git a/packages/engine/src/__tests__/plugin-runner.test.ts b/packages/engine/src/__tests__/plugin-runner.test.ts index 9b45922a8c..65244dfd5f 100644 --- a/packages/engine/src/__tests__/plugin-runner.test.ts +++ b/packages/engine/src/__tests__/plugin-runner.test.ts @@ -39,6 +39,7 @@ describe("PluginRunner", () => { getCliProviderContributions: ReturnType; getPluginSkills: ReturnType; getPluginWorkflowSteps: ReturnType; + getPluginWorkflowExtensions: ReturnType; getPluginWorkflowStepTemplates: ReturnType; getPluginPromptContributions: ReturnType; getPluginSetupInfo: ReturnType; @@ -63,6 +64,7 @@ describe("PluginRunner", () => { off: ReturnType; getTask: ReturnType; getDatabase: ReturnType; + recordRunAuditEvent: ReturnType; }; let pluginRunner: PluginRunner; @@ -104,6 +106,7 @@ describe("PluginRunner", () => { getCliProviderContributions: vi.fn().mockReturnValue([]), getPluginSkills: vi.fn().mockReturnValue([]), getPluginWorkflowSteps: vi.fn().mockReturnValue([]), + getPluginWorkflowExtensions: vi.fn().mockReturnValue([]), getPluginWorkflowStepTemplates: vi.fn().mockReturnValue([]), getPluginPromptContributions: vi.fn().mockReturnValue([]), getPluginSetupInfo: vi.fn().mockReturnValue([]), @@ -127,6 +130,7 @@ describe("PluginRunner", () => { off: mockOff, getTask: vi.fn(), getDatabase: vi.fn().mockReturnValue({ runPluginSchemaInits: mockRunPluginSchemaInits }), + recordRunAuditEvent: vi.fn(), }; mockPluginStore = { @@ -878,17 +882,20 @@ describe("PluginRunner", () => { expect(second).toBe(first); }); - it("returns workflow steps, workflow step templates, prompt contributions, and setup info", async () => { + it("returns workflow steps, workflow extensions, workflow step templates, prompt contributions, and setup info", async () => { const steps = [{ pluginId: "test-plugin", step: { stepId: "ws1", name: "Step", description: "d", mode: "prompt", prompt: "Run checks" } }]; + const extensions = [{ pluginId: "test-plugin", extension: { extensionId: "move-policy", name: "Move Policy", kind: "move-policy", schemaVersion: 1, fallback: "degradeToDefault" } }]; const templates = [{ pluginId: "test-plugin", template: { id: "plugin:test-plugin:ws1", name: "Step", description: "d", prompt: "Run checks", category: "Plugin", icon: "puzzle" } }]; const prompts = [{ pluginId: "test-plugin", contribution: { surface: "executor-system", content: "extra" }, config: { enabledByDefault: true, contributions: [] } }]; const setups = [{ pluginId: "test-plugin", manifest: { binaryName: "agent-browser", description: "Do it" }, hooks: { checkSetup: vi.fn().mockResolvedValue({ status: "installed" }) } }]; mockPluginLoader.getPluginWorkflowSteps.mockReturnValue(steps); + mockPluginLoader.getPluginWorkflowExtensions.mockReturnValue(extensions); mockPluginLoader.getPluginWorkflowStepTemplates.mockReturnValue(templates); mockPluginLoader.getPluginPromptContributions.mockReturnValue(prompts); mockPluginLoader.getPluginSetupInfo.mockReturnValue(setups); await pluginRunner.init(); expect(pluginRunner.getPluginWorkflowSteps()).toEqual(steps); + expect(pluginRunner.getPluginWorkflowExtensions()).toEqual(extensions); expect(pluginRunner.getPluginWorkflowStepTemplates()).toEqual(templates); expect(pluginRunner.getPluginPromptContributions()).toEqual(prompts); expect(pluginRunner.getPluginSetupInfo()).toEqual(setups); @@ -921,6 +928,7 @@ describe("PluginRunner", () => { pluginRunner.getCliProviderContributions(); pluginRunner.getPluginSkills(); pluginRunner.getPluginWorkflowSteps(); + pluginRunner.getPluginWorkflowExtensions(); pluginRunner.getPluginWorkflowStepTemplates(); pluginRunner.getPluginPromptContributions(); pluginRunner.getPluginSetupInfo(); @@ -930,6 +938,7 @@ describe("PluginRunner", () => { pluginRunner.getCliProviderContributions(); pluginRunner.getPluginSkills(); pluginRunner.getPluginWorkflowSteps(); + pluginRunner.getPluginWorkflowExtensions(); pluginRunner.getPluginWorkflowStepTemplates(); pluginRunner.getPluginPromptContributions(); pluginRunner.getPluginSetupInfo(); @@ -939,6 +948,7 @@ describe("PluginRunner", () => { pluginRunner.getCliProviderContributions(); pluginRunner.getPluginSkills(); pluginRunner.getPluginWorkflowSteps(); + pluginRunner.getPluginWorkflowExtensions(); pluginRunner.getPluginWorkflowStepTemplates(); pluginRunner.getPluginPromptContributions(); pluginRunner.getPluginSetupInfo(); @@ -946,6 +956,7 @@ describe("PluginRunner", () => { expect(mockPluginLoader.getCliProviderContributions).toHaveBeenCalledTimes(3); expect(mockPluginLoader.getPluginSkills).toHaveBeenCalledTimes(3); expect(mockPluginLoader.getPluginWorkflowSteps).toHaveBeenCalledTimes(3); + expect(mockPluginLoader.getPluginWorkflowExtensions).toHaveBeenCalledTimes(3); expect(mockPluginLoader.getPluginWorkflowStepTemplates).toHaveBeenCalledTimes(3); expect(mockPluginLoader.getPluginPromptContributions).toHaveBeenCalledTimes(3); expect(mockPluginLoader.getPluginSetupInfo).toHaveBeenCalledTimes(3); diff --git a/packages/engine/src/__tests__/workflow-node-handler-extensions.test.ts b/packages/engine/src/__tests__/workflow-node-handler-extensions.test.ts new file mode 100644 index 0000000000..d0c97c7110 --- /dev/null +++ b/packages/engine/src/__tests__/workflow-node-handler-extensions.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment node + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + WORKFLOW_EXTENSION_SCHEMA_VERSION, + __resetWorkflowExtensionRegistryForTests, + getWorkflowExtensionRegistry, + workflowExtensionRegistryId, + type TaskDetail, + type WorkflowIr, +} from "@fusion/core"; +import { WorkflowGraphExecutor } from "../workflow-graph-executor.js"; + +const settingsOn = { experimentalFeatures: { workflowGraphExecutor: true } }; + +describe("workflow node-handler extensions", () => { + afterEach(() => { + __resetWorkflowExtensionRegistryForTests(); + }); + + it("executes an extension-marked node and routes custom outcomes", async () => { + const extensionKey = workflowExtensionRegistryId("node-plugin", "decision"); + const handle = vi.fn().mockResolvedValue({ + outcome: "outcome:needs-human", + contextPatch: { decidedBy: "plugin" }, + }); + getWorkflowExtensionRegistry().register("node-plugin", { + extensionId: "decision", + name: "Decision", + kind: "node-handler", + nodeKind: "prompt", + schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION, + fallback: "failClosed", + handle, + }); + const workflow: WorkflowIr = { + version: "v2", + name: "node-extension", + columns: [{ id: "work", name: "Work", traits: [] }], + nodes: [ + { id: "start", kind: "start" }, + { id: "decide", kind: "prompt", column: "work", extensions: { [extensionKey]: {} } }, + { id: "human", kind: "prompt", column: "work", config: { prompt: "human" } }, + { id: "default", kind: "end" }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "decide" }, + { from: "decide", to: "human", condition: "outcome:needs-human" }, + { from: "decide", to: "default", condition: "success" }, + { from: "human", to: "end" }, + ], + }; + const prompt = vi.fn(async () => ({ outcome: "success" as const })); + const executor = new WorkflowGraphExecutor({ handlers: { prompt } }); + + const result = await executor.run({ id: "FN-NODE" } as TaskDetail, settingsOn, workflow); + + expect(result.outcome).toBe("success"); + expect(result.visitedNodeIds).toEqual(["start", "decide", "human"]); + expect(result.context).toMatchObject({ decidedBy: "plugin" }); + expect(handle).toHaveBeenCalledWith(expect.objectContaining({ + node: expect.objectContaining({ id: "decide" }), + workflow, + })); + expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ id: "human" }), expect.any(Object)); + }); +}); diff --git a/packages/engine/src/__tests__/workflow-verdict-provider-extensions.test.ts b/packages/engine/src/__tests__/workflow-verdict-provider-extensions.test.ts new file mode 100644 index 0000000000..e78ab7aab6 --- /dev/null +++ b/packages/engine/src/__tests__/workflow-verdict-provider-extensions.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment node + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + WORKFLOW_EXTENSION_SCHEMA_VERSION, + __resetWorkflowExtensionRegistryForTests, + getWorkflowExtensionRegistry, + type TaskDetail, + type WorkflowIr, +} from "@fusion/core"; +import { TaskExecutor } from "../executor.js"; + +describe("workflow verdict-provider extensions", () => { + afterEach(() => { + __resetWorkflowExtensionRegistryForTests(); + }); + + function makeExecutor(workflow: WorkflowIr) { + const store = { + on: vi.fn(), + getTaskWorkflowSelection: vi.fn().mockReturnValue({ workflowId: "custom-workflow", stepIds: [] }), + getWorkflowDefinition: vi.fn().mockResolvedValue({ ir: workflow }), + }; + return new TaskExecutor(store as any, "/tmp/fusion-verdict-provider-test"); + } + + it("allows task completion when all provider verdicts pass", async () => { + const workflow: WorkflowIr = { version: "v2", name: "w", columns: [], nodes: [], edges: [] }; + const task = { id: "FN-PASS", steps: [] } as unknown as TaskDetail; + getWorkflowExtensionRegistry().register("verdict-plugin", { + extensionId: "quality", + name: "Quality", + kind: "verdict-provider", + schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION, + fallback: "failClosed", + evaluate: vi.fn().mockResolvedValue({ status: "pass", summary: "ok" }), + }); + + await expect((makeExecutor(workflow) as any).evaluateTaskVerdictProviders(task)).resolves.toEqual({ ok: true }); + }); + + it("blocks task completion when a provider returns a failing verdict", async () => { + const workflow: WorkflowIr = { version: "v2", name: "w", columns: [], nodes: [], edges: [] }; + const task = { id: "FN-BLOCK", steps: [] } as unknown as TaskDetail; + getWorkflowExtensionRegistry().register("verdict-plugin", { + extensionId: "quality", + name: "Quality", + kind: "verdict-provider", + schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION, + fallback: "failClosed", + evaluate: vi.fn().mockResolvedValue({ + status: "fail", + summary: "quality gate failed", + failureReasons: [{ code: "missing-test", message: "missing regression test" }], + }), + }); + + await expect((makeExecutor(workflow) as any).evaluateTaskVerdictProviders(task)).resolves.toEqual({ + ok: false, + message: "fn_task_done refused (verdict-provider): quality gate failed — missing regression test", + }); + }); +}); diff --git a/packages/engine/src/__tests__/workflow-work-engine-dispatch.test.ts b/packages/engine/src/__tests__/workflow-work-engine-dispatch.test.ts new file mode 100644 index 0000000000..789023f47a --- /dev/null +++ b/packages/engine/src/__tests__/workflow-work-engine-dispatch.test.ts @@ -0,0 +1,84 @@ +// @vitest-environment node + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + WORKFLOW_EXTENSION_SCHEMA_VERSION, + __resetWorkflowExtensionRegistryForTests, + getWorkflowExtensionRegistry, + workflowExtensionRegistryId, + type Task, + type TaskDetail, + type WorkflowIr, +} from "@fusion/core"; +import { TaskExecutor } from "../executor.js"; + +describe("workflow work-engine dispatch", () => { + afterEach(() => { + __resetWorkflowExtensionRegistryForTests(); + }); + + it("lets a plugin work engine claim a task from column extension metadata", async () => { + const extensionKey = workflowExtensionRegistryId("engine-plugin", "custom-dispatch"); + const task = { + id: "FN-WORK", + column: "in-progress", + title: "plugin work", + description: "plugin work", + } as TaskDetail; + const workflow: WorkflowIr = { + version: "v2", + name: "custom", + columns: [ + { id: "todo", name: "Todo", traits: [] }, + { + id: "in-progress", + name: "Running", + traits: [], + extensions: { [extensionKey]: { lane: "custom" } }, + }, + ], + nodes: [], + edges: [], + }; + const dispatch = vi.fn().mockResolvedValue({ + kind: "claimed", + runId: "plugin-run-1", + message: "claimed by plugin", + }); + getWorkflowExtensionRegistry().register("engine-plugin", { + extensionId: "custom-dispatch", + name: "Custom dispatch", + kind: "work-engine", + schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION, + fallback: "failClosed", + dispatch, + }); + + const store = { + on: vi.fn(), + getTask: vi.fn().mockResolvedValue(task), + getTaskWorkflowSelection: vi.fn().mockReturnValue({ workflowId: "custom-workflow", stepIds: [] }), + getWorkflowDefinition: vi.fn().mockResolvedValue({ ir: workflow }), + logEntry: vi.fn().mockResolvedValue(undefined), + recordRunAuditEvent: vi.fn().mockResolvedValue(undefined), + updateTask: vi.fn().mockResolvedValue(undefined), + }; + const executor = new TaskExecutor(store as any, "/tmp/fusion-work-engine-test"); + + const claimed = await (executor as any).maybeDispatchWorkflowWorkEngine(task as Task); + + expect(claimed).toBe(true); + expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ + task, + workflow, + columnId: "in-progress", + metadata: { lane: "custom" }, + })); + expect(store.logEntry).toHaveBeenCalledWith("FN-WORK", "claimed by plugin"); + expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ + mutationType: "workflow:work-engine:claimed", + metadata: expect.objectContaining({ extensionId: extensionKey, pluginId: "engine-plugin" }), + })); + expect(store.updateTask).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/engine/src/auto-merge-fact-providers.ts b/packages/engine/src/auto-merge-fact-providers.ts new file mode 100644 index 0000000000..dcaacd6d97 --- /dev/null +++ b/packages/engine/src/auto-merge-fact-providers.ts @@ -0,0 +1,64 @@ +import { + getWorkflowExtensionRegistry, + resolveWorkflowIrForTask, + type AutoMergeFactProviderResult, + type AutoMergeRoute, + type TaskDetail, + type WorkflowIrResolverStore, +} from "@fusion/core"; + +export interface AutoMergeFactProviderEvaluation { + route?: AutoMergeRoute; + facts: Record; + reasons: string[]; +} + +export async function evaluateAutoMergeFactProviders( + store: WorkflowIrResolverStore, + task: TaskDetail, +): Promise { + const workflow = await resolveWorkflowIrForTask(store, task.id); + const evaluation: AutoMergeFactProviderEvaluation = { facts: {}, reasons: [] }; + + for (const definition of getWorkflowExtensionRegistry().list("merge-fact-provider")) { + const extension = definition.extension; + if (definition.degraded || extension.kind !== "merge-fact-provider" || !extension.collect) continue; + let result: AutoMergeFactProviderResult; + try { + result = await extension.collect({ task, workflow }); + } catch (error) { + if (extension.fallback === "degradeToDefault") continue; + const message = error instanceof Error ? error.message : String(error); + return { + route: "blocked", + facts: evaluation.facts, + reasons: [...evaluation.reasons, `fact provider '${definition.id}' failed: ${message}`], + }; + } + if (result.facts) { + evaluation.facts[definition.id] = result.facts; + } + if (result.reason) { + evaluation.reasons.push(result.reason); + } + if (result.route) { + evaluation.route = chooseStricterAutoMergeRoute(evaluation.route, result.route); + } + } + + return evaluation; +} + +function chooseStricterAutoMergeRoute( + current: AutoMergeRoute | undefined, + next: AutoMergeRoute, +): AutoMergeRoute { + const rank: Record = { + "auto-enqueue": 0, + "workflow-subgraph": 1, + "manual-required": 2, + blocked: 3, + }; + if (!current) return next; + return rank[next] > rank[current] ? next : current; +} diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 66e83d3db2..7dadd996d2 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -9,9 +9,9 @@ import { delimiter, isAbsolute, join, relative, resolve as resolvePath } from "n import { existsSync, realpathSync } from "node:fs"; import { readFile, rm, writeFile } from "node:fs/promises"; import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode } from "@fusion/core"; -import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, isWorkflowColumnsEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId } from "@fusion/core"; +import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, isWorkflowColumnsEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry } from "@fusion/core"; import { mergeEffectiveSettings } from "./effective-settings.js"; -import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput } from "@fusion/core"; +import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput, WorkflowWorkEngineDispatchResult } from "@fusion/core"; import { buildWorkflowObservationFromTask, buildWorkflowObservation, @@ -5556,6 +5556,129 @@ export class TaskExecutor { } } + private async maybeDispatchWorkflowWorkEngine(task: Task): Promise { + let detail: TaskDetail; + let workflow: WorkflowIr; + try { + detail = await this.store.getTask(task.id); + workflow = await resolveWorkflowIrForTask(this.store, task.id); + } catch (error) { + executorLog.warn(`${task.id}: failed to resolve workflow work-engine bindings: ${error instanceof Error ? error.message : String(error)}`); + return false; + } + if (workflow.version !== "v2") return false; + + const column = workflow.columns.find((candidate) => candidate.id === detail.column); + const extensionEntries = Object.entries(column?.extensions ?? {}); + if (extensionEntries.length === 0) return false; + + const registry = getWorkflowExtensionRegistry(); + for (const [extensionId, metadata] of extensionEntries) { + const definition = registry.get(extensionId); + const extension = definition?.extension; + if (!definition || definition.degraded || extension?.kind !== "work-engine" || !extension.dispatch) continue; + + let result: WorkflowWorkEngineDispatchResult; + try { + result = await extension.dispatch({ + task: detail, + workflow, + columnId: detail.column, + metadata, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + executorLog.warn(`${task.id}: workflow work-engine ${extensionId} failed: ${message}`); + if (extension.fallback === "degradeToDefault") continue; + await this.store.logEntry(task.id, `Workflow work engine ${extensionId} failed`, message); + await this.store.updateTask(task.id, { + status: extension.fallback === "parkNeedsAttention" ? "queued" : "failed", + error: message, + }); + return true; + } + + if (result.kind === "not-claimed") continue; + if (result.kind === "degraded-to-default") { + executorLog.warn(`${task.id}: workflow work-engine ${extensionId} degraded to default: ${result.reason}`); + await this.store.logEntry(task.id, `Workflow work engine ${extensionId} degraded to default`, result.reason); + continue; + } + if (result.kind === "parked") { + await this.store.logEntry(task.id, result.message, result.reason); + await this.store.updateTask(task.id, { status: "queued", error: result.reason }); + return true; + } + + await this.store.logEntry( + task.id, + result.message ?? `Workflow work engine ${extensionId} claimed execution`, + ); + try { + await this.store.recordRunAuditEvent?.({ + taskId: task.id, + agentId: "workflow-work-engine", + runId: result.runId ?? generateSyntheticRunId("workflow-work-engine", task.id), + domain: "database", + mutationType: "workflow:work-engine:claimed", + target: task.id, + metadata: { + extensionId, + columnId: detail.column, + pluginId: definition.pluginId, + }, + }); + } catch (error) { + executorLog.warn(`${task.id}: failed to record workflow work-engine claim audit: ${error instanceof Error ? error.message : String(error)}`); + } + return true; + } + + return false; + } + + private async evaluateTaskVerdictProviders( + task: TaskDetail, + context: Record = {}, + ): Promise<{ ok: true } | { ok: false; message: string }> { + let workflow: WorkflowIr; + try { + workflow = await resolveWorkflowIrForTask(this.store, task.id); + } catch (error) { + executorLog.warn(`${task.id}: failed to resolve workflow for verdict providers: ${error instanceof Error ? error.message : String(error)}`); + return { ok: true }; + } + + const providers = getWorkflowExtensionRegistry().list("verdict-provider"); + for (const definition of providers) { + const extension = definition.extension; + if (definition.degraded || extension.kind !== "verdict-provider" || !extension.evaluate) continue; + try { + const verdict = await extension.evaluate({ + task, + workflow, + reworkRound: 0, + metadata: context, + }); + if (verdict.status === "pass") continue; + const reasons = verdict.failureReasons?.map((reason) => reason.message).filter(Boolean).join("; "); + return { + ok: false, + message: `fn_task_done refused (verdict-provider): ${verdict.summary}${reasons ? ` — ${reasons}` : ""}`, + }; + } catch (error) { + if (extension.fallback === "degradeToDefault") continue; + const message = error instanceof Error ? error.message : String(error); + return { + ok: false, + message: `fn_task_done refused (verdict-provider): provider '${definition.id}' failed — ${message}`, + }; + } + } + + return { ok: true }; + } + async execute(task: Task): Promise { // Workflow graph interpreter routing (cutover M-C): graph-selected tasks // are orchestrated by the interpreter. The execute seam re-enters this @@ -5599,6 +5722,13 @@ export class TaskExecutor { return; } + if (await this.maybeDispatchWorkflowWorkEngine(task)) { + executorLog.log(`${task.id}: workflow work engine claimed execution`); + this.executing.delete(task.id); + executingTaskLock.release(task.id); + return; + } + // Column-agent principal alignment (plan U5, R6): the heartbeat-deferral gate // must consult the EFFECTIVE principal, not blindly `assignedAgentId`. For a // graph-routed seam the binding context (governing node id + per-run resolver) @@ -9017,6 +9147,21 @@ export class TaskExecutor { }; } + const providerVerdict = await this.evaluateTaskVerdictProviders(task, { + summary: params.summary, + source: "fn_task_done", + }); + if (!providerVerdict.ok) { + await store.logEntry(taskId, providerVerdict.message, undefined, this.getRunContextFor(task.id)); + executorLog.error(`${taskId}: ${providerVerdict.message}`); + return { + content: [{ type: "text" as const, text: providerVerdict.message }], + details: { + error: providerVerdict.message, + }, + }; + } + const invariantCheck = await this.verifyWorktreeInvariants(task, worktreePath); if (!invariantCheck.ok) { const refusalMessage = `fn_task_done refused: ${invariantCheck.reason} — observed=${invariantCheck.observed}, expected=${invariantCheck.expected}`; diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index 4a180c63f0..23ae662b67 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -89,10 +89,12 @@ import { type PostMergeAuditMode, type TaskSourceIssue, type Task, + type TaskDetail, type AutostashOrphanRecord, normalizeMergeAdvanceAutoSyncMode, isMergeRequestContractShadowEnabled, } from "@fusion/core"; +import { evaluateAutoMergeFactProviders } from "./auto-merge-fact-providers.js"; import { resolveMergePolicy, type MergeFileScopeMode } from "./merge-trait.js"; import { describeModel, promptWithFallback } from "./pi.js"; import { accumulateSessionTokenUsage } from "./session-token-usage.js"; @@ -8149,7 +8151,15 @@ export async function aiMergeTask( } if (isMergeRequestContractShadowEnabled(settings)) { - const initialState = task.autoMerge === false ? "manual-required" : "queued"; + const autoMergeFacts = await evaluateAutoMergeFactProviders(store, task as TaskDetail).catch((error) => ({ + route: "blocked" as const, + facts: {}, + reasons: [`auto-merge fact provider evaluation failed: ${error instanceof Error ? error.message : String(error)}`], + })); + const providerManualRoute = + autoMergeFacts.route === "manual-required" || + autoMergeFacts.route === "blocked"; + const initialState = task.autoMerge === false || providerManualRoute ? "manual-required" : "queued"; const existingRecord = store.getMergeRequestRecord(task.id); const currentState = existingRecord?.state ?? initialState; if (!existingRecord) { @@ -8171,6 +8181,9 @@ export async function aiMergeTask( metadata: { taskId: task.id, state: initialState, + autoMergeProviderRoute: autoMergeFacts.route ?? null, + autoMergeProviderReasons: autoMergeFacts.reasons, + autoMergeProviderFacts: autoMergeFacts.facts, integrationMode: integrationRoot.mode === "reuse-task-worktree" ? "reuse-task-worktree" : "cwd-integration", }, }); diff --git a/packages/engine/src/plugin-runner.ts b/packages/engine/src/plugin-runner.ts index 68c13e9726..95ef7ddd29 100644 --- a/packages/engine/src/plugin-runner.ts +++ b/packages/engine/src/plugin-runner.ts @@ -21,6 +21,7 @@ import type { PluginContext, PluginSkillContribution, PluginWorkflowStepContribution, + WorkflowExtensionContribution, PluginTraitContribution, WorkflowIr, PluginPromptContribution, @@ -36,6 +37,7 @@ import { Type } from "@earendil-works/pi-ai"; import { isAbsolute } from "node:path"; import { getTraitRegistry, + getWorkflowExtensionRegistry, resolveWorkflowIrForTask, } from "@fusion/core"; import { createLogger, executorLog } from "./logger.js"; @@ -54,6 +56,11 @@ import { unregisterPluginStepParsers, type PluginStepParserContribution, } from "./plugin-parser-adapter.js"; +import { + degradePluginWorkflowExtensions, + registerPluginWorkflowExtensions, + unregisterPluginWorkflowExtensions, +} from "./plugin-workflow-extension-adapter.js"; // Type for the task store's event data interface TaskMovedEvent { @@ -122,6 +129,11 @@ interface CachedWorkflowSteps { version: number; } +interface CachedWorkflowExtensions { + extensions: Array<{ pluginId: string; extension: WorkflowExtensionContribution }>; + version: number; +} + interface CachedWorkflowStepTemplates { templates: Array<{ pluginId: string; template: WorkflowStepTemplate }>; version: number; @@ -158,6 +170,7 @@ export class PluginRunner { private cachedCliProviderContributions: CachedCliProviderContributions | null = null; private cachedSkills: CachedSkills | null = null; private cachedWorkflowSteps: CachedWorkflowSteps | null = null; + private cachedWorkflowExtensions: CachedWorkflowExtensions | null = null; private cachedWorkflowStepTemplates: CachedWorkflowStepTemplates | null = null; private cachedTraits: CachedTraits | null = null; private cachedPromptContributions: CachedPromptContributions | null = null; @@ -170,11 +183,14 @@ export class PluginRunner { private cliProviderContributionsCacheVersion = 0; private skillsCacheVersion = 0; private workflowStepsCacheVersion = 0; + private workflowExtensionsCacheVersion = 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(); + /** Map of pluginId → the workflow extension ids it currently has registered. */ + private registeredPluginWorkflowExtensionIds = new Map(); /** Map of pluginId → the step-parser registry ids it currently has registered * (U12, KTD-12; mirrors registeredPluginTraitIds). */ private registeredPluginParserIds = new Map(); @@ -256,6 +272,7 @@ export class PluginRunner { this.invalidateCliProviderContributionsCache(); this.invalidateSkillsCache(); this.invalidateWorkflowStepsCache(); + this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowStepTemplatesCache(); this.invalidateTraitsCache(); this.invalidatePromptContributionsCache(); @@ -396,6 +413,21 @@ export class PluginRunner { return this.cachedWorkflowSteps.steps; } + getPluginWorkflowExtensions(): Array<{ pluginId: string; extension: WorkflowExtensionContribution }> { + if (!this.cachedWorkflowExtensions || this.cachedWorkflowExtensions.version !== this.workflowExtensionsCacheVersion) { + const loader = this.options.pluginLoader as unknown as { + getPluginWorkflowExtensions?: () => Array<{ pluginId: string; extension: WorkflowExtensionContribution }>; + }; + this.cachedWorkflowExtensions = { + extensions: typeof loader.getPluginWorkflowExtensions === "function" + ? loader.getPluginWorkflowExtensions() + : [], + version: this.workflowExtensionsCacheVersion, + }; + } + return this.cachedWorkflowExtensions.extensions; + } + /** * Get all plugin trait contributions with their plugin ids (U8). Aggregated / * cached / invalidated exactly like workflow steps. @@ -479,6 +511,78 @@ export class PluginRunner { } } + syncPluginWorkflowExtensions(): void { + const registry = getWorkflowExtensionRegistry(); + const current = this.getPluginWorkflowExtensions(); + + const byPlugin = new Map(); + for (const { pluginId, extension } of current) { + const list = byPlugin.get(pluginId) ?? []; + list.push(extension); + byPlugin.set(pluginId, list); + } + + for (const [pluginId, ids] of [...this.registeredPluginWorkflowExtensionIds.entries()]) { + if (!byPlugin.has(pluginId)) { + unregisterPluginWorkflowExtensions(registry, ids); + this.registeredPluginWorkflowExtensionIds.delete(pluginId); + } + } + + for (const [pluginId, contributions] of byPlugin) { + try { + const previous = this.registeredPluginWorkflowExtensionIds.get(pluginId); + if (previous) unregisterPluginWorkflowExtensions(registry, previous); + const ids = registerPluginWorkflowExtensions({ registry, pluginId, contributions }); + this.registeredPluginWorkflowExtensionIds.set(pluginId, ids); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.log.warn(`Failed to register workflow extensions for plugin '${pluginId}': ${msg}`); + } + } + } + + disablePluginWorkflowExtensions(pluginId: string, opts?: { force?: boolean }): { + degraded: string[]; + dependents: []; + } { + const registry = getWorkflowExtensionRegistry(); + const ids = this.collectPluginWorkflowExtensionIds(pluginId); + if (!opts?.force) { + unregisterPluginWorkflowExtensions(registry, ids); + this.registeredPluginWorkflowExtensionIds.delete(pluginId); + return { degraded: [], dependents: [] }; + } + const degraded = degradePluginWorkflowExtensions(registry, ids); + if (degraded.length > 0) { + try { + this.options.taskStore.recordRunAuditEvent({ + agentId: "system", + runId: `plugin-workflow-extension-degrade-${pluginId}-${Date.now()}`, + domain: "database", + mutationType: "plugin:workflow-extension-degraded", + target: pluginId, + metadata: { + pluginId, + degradedExtensionIds: degraded, + note: "workflow extension handlers are degraded by fallback policy", + }, + }); + } catch { + // Audit is best-effort; degradation already applied. + } + } + return { degraded, dependents: [] }; + } + + private collectPluginWorkflowExtensionIds(pluginId: string): string[] { + const tracked = this.registeredPluginWorkflowExtensionIds.get(pluginId); + if (tracked && tracked.length > 0) return tracked; + return this.getPluginWorkflowExtensions() + .filter((entry) => entry.pluginId === pluginId) + .map((entry) => `plugin:${pluginId}:${entry.extension.extensionId}`); + } + /** * Register all currently-loaded plugins' step-parser contributions into the * core StepParserRegistry (plugin-namespaced ids, U12/KTD-12). Mirrors @@ -807,6 +911,7 @@ export class PluginRunner { this.invalidateCliProviderContributionsCache(); this.invalidateSkillsCache(); this.invalidateWorkflowStepsCache(); + this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowStepTemplatesCache(); this.invalidateTraitsCache(); this.invalidatePromptContributionsCache(); @@ -829,6 +934,7 @@ export class PluginRunner { this.invalidateCliProviderContributionsCache(); this.invalidateSkillsCache(); this.invalidateWorkflowStepsCache(); + this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowStepTemplatesCache(); this.invalidateTraitsCache(); this.invalidatePromptContributionsCache(); @@ -856,6 +962,7 @@ export class PluginRunner { this.invalidateCliProviderContributionsCache(); this.invalidateSkillsCache(); this.invalidateWorkflowStepsCache(); + this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowStepTemplatesCache(); this.invalidateTraitsCache(); this.invalidatePromptContributionsCache(); @@ -883,6 +990,7 @@ export class PluginRunner { this.invalidateCliProviderContributionsCache(); this.invalidateSkillsCache(); this.invalidateWorkflowStepsCache(); + this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowStepTemplatesCache(); this.invalidateTraitsCache(); this.invalidatePromptContributionsCache(); @@ -909,6 +1017,7 @@ export class PluginRunner { this.invalidateCliProviderContributionsCache(); this.invalidateSkillsCache(); this.invalidateWorkflowStepsCache(); + this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowStepTemplatesCache(); this.invalidateTraitsCache(); this.invalidatePromptContributionsCache(); @@ -927,6 +1036,7 @@ export class PluginRunner { this.invalidateCliProviderContributionsCache(); this.invalidateSkillsCache(); this.invalidateWorkflowStepsCache(); + this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowStepTemplatesCache(); this.invalidateTraitsCache(); this.invalidatePromptContributionsCache(); @@ -945,6 +1055,7 @@ export class PluginRunner { this.invalidateCliProviderContributionsCache(); this.invalidateSkillsCache(); this.invalidateWorkflowStepsCache(); + this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowStepTemplatesCache(); this.invalidateTraitsCache(); this.invalidatePromptContributionsCache(); @@ -963,6 +1074,7 @@ export class PluginRunner { this.invalidateCliProviderContributionsCache(); this.invalidateSkillsCache(); this.invalidateWorkflowStepsCache(); + this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowStepTemplatesCache(); this.invalidateTraitsCache(); this.invalidatePromptContributionsCache(); @@ -981,6 +1093,7 @@ export class PluginRunner { this.invalidateCliProviderContributionsCache(); this.invalidateSkillsCache(); this.invalidateWorkflowStepsCache(); + this.invalidateWorkflowExtensionsCache(); this.invalidateWorkflowStepTemplatesCache(); this.invalidateTraitsCache(); this.invalidatePromptContributionsCache(); @@ -1210,6 +1323,12 @@ export class PluginRunner { this.log.log(`Workflow steps cache invalidated (version: ${this.workflowStepsCacheVersion})`); } + private invalidateWorkflowExtensionsCache(): void { + this.workflowExtensionsCacheVersion++; + this.log.log(`Workflow extensions cache invalidated (version: ${this.workflowExtensionsCacheVersion})`); + this.syncPluginWorkflowExtensions(); + } + private invalidateWorkflowStepTemplatesCache(): void { this.workflowStepTemplatesCacheVersion++; this.log.log(`Workflow step templates cache invalidated (version: ${this.workflowStepTemplatesCacheVersion})`); diff --git a/packages/engine/src/plugin-workflow-extension-adapter.ts b/packages/engine/src/plugin-workflow-extension-adapter.ts new file mode 100644 index 0000000000..7663adfd40 --- /dev/null +++ b/packages/engine/src/plugin-workflow-extension-adapter.ts @@ -0,0 +1,40 @@ +import { + type WorkflowExtensionContribution, + type WorkflowExtensionRegistry, + workflowExtensionRegistryId, +} from "@fusion/core"; + +export function registerPluginWorkflowExtensions(params: { + registry: WorkflowExtensionRegistry; + pluginId: string; + contributions: WorkflowExtensionContribution[]; +}): string[] { + const registered: string[] = []; + for (const contribution of params.contributions) { + const id = workflowExtensionRegistryId(params.pluginId, contribution.extensionId); + if (!params.registry.get(id)) { + params.registry.register(params.pluginId, contribution); + } + registered.push(id); + } + return registered; +} + +export function unregisterPluginWorkflowExtensions( + registry: WorkflowExtensionRegistry, + ids: readonly string[], +): string[] { + const removed: string[] = []; + for (const id of ids) { + if (registry.unregister(id)) removed.push(id); + } + return removed; +} + +export function degradePluginWorkflowExtensions( + registry: WorkflowExtensionRegistry, + ids: readonly string[], + message = "workflow extension plugin force-disabled", +): string[] { + return registry.degrade(ids, "force-disabled", message); +} diff --git a/packages/engine/src/workflow-graph-executor.ts b/packages/engine/src/workflow-graph-executor.ts index edb3c76a06..aeb695a90d 100644 --- a/packages/engine/src/workflow-graph-executor.ts +++ b/packages/engine/src/workflow-graph-executor.ts @@ -1,5 +1,5 @@ -import type { Settings, TaskDetail, TaskStep, WorkflowIr, WorkflowIrEdge, WorkflowIrNode } from "@fusion/core"; -import { BUILTIN_CODING_WORKFLOW_IR, WorkflowIrError, isExperimentalFeatureEnabled, resolveMaxReworkCycles } from "@fusion/core"; +import type { Settings, TaskDetail, TaskStep, WorkflowIr, WorkflowIrEdge, WorkflowIrNode, WorkflowNodeExtensionResult } from "@fusion/core"; +import { BUILTIN_CODING_WORKFLOW_IR, WorkflowIrError, getWorkflowExtensionRegistry, isExperimentalFeatureEnabled, resolveMaxReworkCycles } from "@fusion/core"; import { createDefaultNodeHandlers, @@ -249,7 +249,7 @@ export class WorkflowGraphExecutor { runId, nodeMap, outgoingMap, - runBranchNode: (node, signal) => this.executeNodeWithRetries(node, task, settings, context, signal), + runBranchNode: (node, signal) => this.executeNodeWithRetries(node, task, settings, context, ir, signal), shouldTraverseEdge: (edge, source) => this.shouldTraverseEdge(edge, source), persistence: this.deps.branchPersistence, semaphore: this.deps.branchSemaphore, @@ -314,7 +314,7 @@ export class WorkflowGraphExecutor { steps, context, runTemplateNode: (tNode, sig, contextOverride) => - this.executeNodeWithRetries(tNode, task, settings, contextOverride ?? context, sig), + this.executeNodeWithRetries(tNode, task, settings, contextOverride ?? context, ir, sig), shouldTraverseEdge: (edge, src) => this.shouldTraverseEdge(edge, src), persistence: this.deps.stepInstancePersistence, onReworkReset: this.deps.onReworkReset, @@ -338,7 +338,7 @@ export class WorkflowGraphExecutor { return await traverseChildren(node, result); } - const result = await this.executeNodeWithRetries(node, task, settings, context); + const result = await this.executeNodeWithRetries(node, task, settings, context, ir); if (result.contextPatch) Object.assign(context, result.contextPatch); context[`node:${node.id}:outcome`] = result.outcome; if (result.value !== undefined) context[`node:${node.id}:value`] = result.value; @@ -488,17 +488,67 @@ export class WorkflowGraphExecutor { throw new WorkflowIrError(`Unsupported edge condition: ${edge.condition}`); } + private normalizePluginNodeResult(result: WorkflowNodeExtensionResult): WorkflowNodeResult { + if (result.outcome === "success" || result.outcome === "failure") { + return result; + } + return { + outcome: "success", + value: result.outcome.slice("outcome:".length), + contextPatch: result.contextPatch, + }; + } + + private async executePluginNodeHandler( + node: WorkflowIrNode, + task: TaskDetail, + workflow: WorkflowIr, + context: Record, + signal?: AbortSignal, + ): Promise { + const extensionIds = Object.keys(node.extensions ?? {}); + if (extensionIds.length === 0) return undefined; + + const registry = getWorkflowExtensionRegistry(); + for (const extensionId of extensionIds) { + const definition = registry.get(extensionId); + const extension = definition?.extension; + if (!definition || definition.degraded || extension?.kind !== "node-handler" || !extension.handle) continue; + if (extension.nodeKind && extension.nodeKind !== node.kind) continue; + try { + const result = await extension.handle({ + task, + workflow, + node, + context, + signal, + }); + return this.normalizePluginNodeResult(result); + } catch (error) { + if (extension.fallback === "degradeToDefault") continue; + return { + outcome: "failure", + value: "plugin-node-handler-error", + contextPatch: { + [`node:${node.id}:error`]: error instanceof Error ? error.message : String(error), + [`node:${node.id}:extensionId`]: extensionId, + }, + }; + } + } + + return undefined; + } + private async executeNodeWithRetries( node: WorkflowIrNode, task: TaskDetail, settings: Pick | undefined, context: Record, + workflow: WorkflowIr, signal?: AbortSignal, ): Promise { const handler = this.handlers[node.kind]; - if (!handler) { - throw new WorkflowIrError(`No handler registered for node kind: ${node.kind}`); - } // Per-node override: config.maxRetries beats the executor-wide default. const configured = Number(node.config?.maxRetries); @@ -511,6 +561,11 @@ export class WorkflowGraphExecutor { // Fail-fast cancellation: a branch aborted mid-retry stops re-trying. if (signal?.aborted) return { outcome: "failure", value: "aborted" }; try { + const pluginResult = await this.executePluginNodeHandler(node, task, workflow, context, signal); + if (pluginResult) return pluginResult; + if (!handler) { + throw new WorkflowIrError(`No handler registered for node kind: ${node.kind}`); + } return await handler(node, { task, settings, context, signal }); } catch (error) { lastError = error; diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 1b03434db9..f6f7097212 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -81,6 +81,36 @@ export type { PluginTraitContribution, PluginTraitHookDescriptor, PluginTraitFlags, + WorkflowExtensionContribution, + WorkflowExtensionMetadata, + WorkflowExtensionBaseContribution, + WorkflowColumnMetadataExtensionContribution, + WorkflowMovePolicyExtensionContribution, + WorkflowWorkEngineExtensionContribution, + WorkflowNodeHandlerExtensionContribution, + TaskVerdictProviderExtensionContribution, + AutoMergeFactProviderExtensionContribution, + WorkflowExtensionConfigField, + WorkflowExtensionConfigSchema, + WorkflowExtensionFallback, + WorkflowExtensionKind, + WorkflowMovePolicyDecision, + WorkflowMovePolicyInput, + WorkflowMovePolicyHandler, + WorkflowWorkEngineDispatchResult, + WorkflowWorkEngineInput, + WorkflowWorkEngineHandler, + WorkflowNodeExtensionResult, + WorkflowNodeHandlerInput, + WorkflowNodeExtensionHandler, + TaskVerdictStatus, + TaskVerdictProviderInput, + TaskVerdictProviderResult, + TaskVerdictProviderHandler, + AutoMergeRoute, + AutoMergeFactProviderInput, + AutoMergeFactProviderResult, + AutoMergeFactProviderHandler, PluginPromptSurface, PluginPromptContribution, PluginPromptContributions, @@ -94,6 +124,16 @@ export type { FusionPlugin, PluginState, PluginInstallation, + BoardActionServices, + BoardActionTaskStore, + MoveBoardTaskInput, + UpdateBoardTaskInput, +} from "@fusion/core"; + +export { + WORKFLOW_EXTENSION_SCHEMA_VERSION, + workflowExtensionRegistryId, + createBoardActionServices, } from "@fusion/core"; // ── Step-inversion IR types (type-only) ──────────────────────────────────────