refactor(workflow): extract node runner boundaries
This commit is contained in:
@@ -108,6 +108,12 @@ The pinned `claude-code-cli-acp` subprocess bundled with the ACP runtime plugin.
|
||||
### Runtime Primitive
|
||||
A named, injected operation a workflow node can call to perform side effects without depending on `executor.ts` lifecycle branches. Examples include planning session, coding session, step execution/reset, review, verification, workflow step, transition, merge request, abort, and audit. Primitives are the boundary between workflow policy and engine substrate.
|
||||
|
||||
### Workflow Node Runner
|
||||
An engine-owned implementation of one workflow node kind. Workflow IR stays declarative; runners execute known node behavior with typed dependencies and return graph-native outcomes. The graph executor owns traversal and routing, while runners own node behavior such as gate, notify, parse-steps, code, merge, custom-node dispatch, review, and planning service calls.
|
||||
|
||||
### Workflow Runtime Service
|
||||
A narrow substrate-facing service used by workflow node runners or primitives for behavior that used to live directly inside monolithic executor, reviewer, or triage paths. Current examples include runtime primitive providers, custom-node execution, single-cwd review invocation, and graph planning. Services keep hard safety invariants in the engine substrate while making node behavior testable without constructing the full executor when a smaller fake is enough.
|
||||
|
||||
### Built-in Lifecycle Node
|
||||
A node in a built-in workflow that expresses default Fusion behavior, such as planning, execute, review, merge, parse-steps, step-review, or PR lifecycle actions. Built-in lifecycle nodes are the compatibility layer for existing behavior: changing default execution means changing the built-in workflow and its primitive wiring, not adding hidden imperative branches.
|
||||
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
---
|
||||
title: "refactor: Extract workflow node runner classes"
|
||||
date: 2026-06-30
|
||||
type: refactor
|
||||
depth: deep
|
||||
artifact_contract: ce-unified-plan/v1
|
||||
artifact_readiness: implementation-ready
|
||||
product_contract_source: ce-plan-bootstrap
|
||||
execution: code
|
||||
---
|
||||
|
||||
# Refactor Workflow Node Runner Classes Plan
|
||||
|
||||
## Goal Capsule
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Objective | Refactor workflow lifecycle logic out of monolithic triage, executor, and reviewer files into well-defined workflow node runner modules/classes that call runtime primitives through explicit dependencies. |
|
||||
| Authority | User request in this session: assess and plan whether workflow logic can be moved from monolithic triage/executor/reviewer files into well-defined node classes. Existing product direction says workflow policy belongs in workflow nodes and runtime primitives while engine substrate keeps hard safety invariants. |
|
||||
| Execution profile | Deep architecture refactor across `@fusion/engine` workflow dispatch, runtime primitive adapters, custom node execution, reviewer invocation, triage planning review, and focused parity tests. |
|
||||
| Stop conditions | Do not change workflow behavior while extracting boundaries. Do not weaken worktree, file-scope, merge-proof, pause/abort, semaphore, active-session, or self-healing invariants. Do not add arbitrary workflow-definition code execution. |
|
||||
| Tail ownership | Ship in small slices with characterization tests first. Add a changeset only when behavior of published `@runfusion/fusion` changes; pure internal refactor slices do not need one. |
|
||||
|
||||
---
|
||||
|
||||
## Product Contract
|
||||
|
||||
### Summary
|
||||
|
||||
Fusion already has workflow IR, a `WorkflowGraphExecutor`, a `WorkflowTaskRuntime`, and a `WorkflowRuntimePrimitives` boundary.
|
||||
The remaining pain is that major lifecycle mechanics still live in large imperative files: `executor.ts` owns primitive implementations, custom node execution, task-done/review tools, session setup, worktree handling, and many recovery hooks; `triage.ts` still owns planning-session and plan-review behavior; `reviewer.ts` exposes one large review function used from both executor and triage.
|
||||
|
||||
The target is a node-runner architecture, not workflow-authored arbitrary code blocks.
|
||||
Workflow definitions remain declarative.
|
||||
Engine-owned node runner modules/classes implement node behavior with typed dependencies, call runtime primitives or narrow substrate services, and return graph-native outcomes.
|
||||
`WorkflowGraphExecutor` should walk and route the graph; node runners should own node-kind behavior; `TaskExecutor`, `TriageProcessor`, and `reviewStep` should shrink into substrate adapters and session services.
|
||||
|
||||
### Problem Frame
|
||||
|
||||
`packages/engine/src/executor.ts` is over 17k lines and currently acts as task orchestrator, primitive provider, custom workflow node runner, tool factory, worktree/session manager, review bridge, merge boundary, and recovery participant.
|
||||
That coupling makes workflow behavior hard to reason about because a graph node can still delegate into hidden executor branches, while tests often call private executor methods directly.
|
||||
The repo's current concepts and docs already define a cleaner split: workflow policy in IR/node routing, runtime primitives as the side-effect boundary, and engine substrate for safety, scheduling, persistence, process supervision, and audit.
|
||||
|
||||
### Requirements
|
||||
|
||||
- R1. Workflow node behavior currently implemented through `createDefaultNodeHandlers`, `runGraphCustomNode`, `createAuthoritativeWorkflowPrimitives`, triage planning paths, and reviewer calls must move toward explicit node runner modules/classes with narrow dependencies.
|
||||
- R2. `WorkflowGraphExecutor` must remain a graph walker and outcome router, not a holder of side-effecting lifecycle logic.
|
||||
- R3. Workflow definitions must stay declarative; the refactor must not introduce user-authored arbitrary code blocks inside workflow IR.
|
||||
- R4. Existing workflow behavior must be preserved during extraction, including built-in coding, stepwise coding, custom prompt/script/gate nodes, optional groups, parse-steps, code nodes, notify nodes, PR nodes, merge nodes, and fast-mode behavior.
|
||||
- R5. Safety invariants remain substrate-owned: worktree acquisition and ownership, active-session leases, file-scope checks, branch/merge proof, process supervision, semaphores, task transition validation, pause/abort semantics, and recovery gates.
|
||||
- R6. Node runner dependencies must make side effects explicit and testable without instantiating full `TaskExecutor` when a narrower fake can prove behavior.
|
||||
- R7. Legacy seams and private executor test paths should be retired incrementally only after parity tests cover the equivalent runner path.
|
||||
- R8. Planning/triage and review behavior should expose node-compatible services so plan, plan-review, review, and step-review nodes do not have to reach into triage or reviewer monoliths.
|
||||
- R9. The refactor must support plugin-contributed workflow node handlers and existing workflow extensions without forcing plugins to import executor internals.
|
||||
- R10. Documentation and concepts must stay aligned so future work understands where to add a workflow behavior: IR, runner, primitive adapter, or substrate service.
|
||||
|
||||
### Scope Boundaries
|
||||
|
||||
In scope:
|
||||
|
||||
- Engine-local node runner interface and registry.
|
||||
- Migration of existing function handlers into runner modules/classes.
|
||||
- Extraction of runtime primitive adapter creation out of `TaskExecutor` into narrower provider modules.
|
||||
- Extraction of custom prompt/script/gate node execution out of `TaskExecutor.runGraphCustomNode`.
|
||||
- Review and planning service seams needed by review/plan nodes.
|
||||
- Characterization and parity tests for the runner registry, primitive adapters, custom-node execution, review invocation, and workflow graph behavior.
|
||||
- Updates to workflow runtime docs and concepts.
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Changing workflow IR schema for arbitrary user code blocks.
|
||||
- Rewriting scheduler, merger, task store, self-healing, or dashboard workflow editor wholesale.
|
||||
- Removing `TaskExecutor`, `TriageProcessor`, or `reviewStep` in one pass.
|
||||
- Broad behavior changes already covered by `docs/plans/2026-06-29-002-workflow-node-lifecycle-authority-plan.md`.
|
||||
- Moving hard invariants into workflow definitions or plugin code.
|
||||
|
||||
### Acceptance Examples
|
||||
|
||||
- AE1. Given a built-in coding workflow reaches `execute`, the graph invokes an execute node runner that uses runtime primitives and preserves current worktree/session/task-done behavior.
|
||||
- AE2. Given a custom prompt/script/gate node, the graph invokes a custom node runner service rather than a private `TaskExecutor.runGraphCustomNode` method, while preserving model, agent, skill, CLI, `cli-agent`, await-input, fast-mode, column-agent, approval, and write-capable worktree behavior.
|
||||
- AE3. Given a step-review node in a foreach template, the runner calls a review service through explicit dependencies and preserves advisory/single-writer, workspace per-repo review, unavailable retry, and verdict routing.
|
||||
- AE4. Given existing tests that instantiate `WorkflowGraphExecutor` with fake primitives, runner dispatch remains unit-testable without a full executor.
|
||||
- AE5. Given a plugin-contributed workflow extension, plugin node handling remains opt-in through registry/extension wiring and does not depend on executor private methods.
|
||||
- AE6. Given a paused, aborted, or restarted workflow run, node runner extraction does not change re-entry, retry budget, task status, or run-audit behavior.
|
||||
- AE7. Given old legacy seam tests, either they remain on an explicit compatibility adapter or are replaced by runner/primitive parity tests that prove the same outcomes.
|
||||
|
||||
---
|
||||
|
||||
## Planning Contract
|
||||
|
||||
### Key Technical Decisions
|
||||
|
||||
- KTD-1. Introduce an engine-owned `WorkflowNodeRunner` contract before moving behavior.
|
||||
The first slice should add a runner interface and registry that adapts to the existing `WorkflowNodeHandler` shape.
|
||||
This avoids a big-bang rewrite and lets each node kind migrate independently.
|
||||
|
||||
- KTD-2. Keep runtime primitives as the node side-effect boundary.
|
||||
Node runners should call `WorkflowRuntimePrimitives` or narrow injected services, not broad `TaskExecutor` methods.
|
||||
Existing primitive names are the compatibility contract for planning, coding sessions, task-step execution, review, transition, merge, artifact I/O, and audit.
|
||||
|
||||
- KTD-3. Treat `TaskExecutor` as an adapter provider during migration.
|
||||
`TaskExecutor.createAuthoritativeWorkflowPrimitives` and `runGraphCustomNode` should be split into factory/service modules, but the first passes can delegate through `TaskExecutor` to preserve behavior.
|
||||
Delete private executor entry points only after equivalent tests exist on the new services.
|
||||
|
||||
- KTD-4. Custom node execution deserves its own service before class-by-class migration.
|
||||
Custom prompt/script/gate nodes carry the most executor-specific behavior: executor mode, model/agent/skill/CLI/CLI-agent resolution, column-agent adoption, fast mode, write-capable worktree guards, await-input, approval pause, task documents, artifacts, and workflow tool exposure.
|
||||
Extracting that as `WorkflowCustomNodeExecutionService` creates a real seam without scattering behavior across many node classes.
|
||||
|
||||
- KTD-5. Review and planning need service seams, not direct monolith imports.
|
||||
`reviewStep` can remain the low-level reviewer implementation initially, but runners should depend on a `WorkflowReviewService`.
|
||||
Triage planning and plan-review paths should similarly expose a `WorkflowPlanningService` or primitive adapter so plan nodes do not reach into `TriageProcessor` internals.
|
||||
|
||||
- KTD-6. Runner registry must preserve plugin and extension order.
|
||||
Built-in runners should be registered by kind, then plugin/extension handlers can override or add only where currently allowed.
|
||||
Missing dependencies must continue failing closed with clear outcome values instead of silently passing.
|
||||
|
||||
- KTD-7. Use characterization-first sequencing for monolith extraction.
|
||||
Before moving each behavior out of `executor.ts`, add or relocate tests that assert current outcomes through graph/runtime public seams.
|
||||
Avoid private-method tests as the final proof; they are acceptable only as temporary characterization while the extraction is underway.
|
||||
|
||||
### High-Level Technical Design
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
IR[Workflow IR node] --> Graph[WorkflowGraphExecutor]
|
||||
Graph --> Registry[WorkflowNodeRunnerRegistry]
|
||||
Registry --> Builtin[Built-in node runners]
|
||||
Registry --> Plugin[Plugin extension runners]
|
||||
Builtin --> Primitives[WorkflowRuntimePrimitives]
|
||||
Builtin --> Services[Node services]
|
||||
Plugin --> Primitives
|
||||
Services --> Sessions[Session/worktree/review/planning services]
|
||||
Primitives --> Substrate[Engine substrate]
|
||||
Substrate --> Store[Task store / audit]
|
||||
Substrate --> Worktree[Worktree + active-session leases]
|
||||
Substrate --> Agents[Agent sessions + semaphore]
|
||||
Substrate --> Merge[Merger + proof]
|
||||
```
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Graph as WorkflowGraphExecutor
|
||||
participant Registry as Runner registry
|
||||
participant Runner as WorkflowNodeRunner
|
||||
participant Primitive as Runtime primitive
|
||||
participant Service as Substrate service
|
||||
participant Store as TaskStore/audit
|
||||
|
||||
Graph->>Registry: resolve node.kind
|
||||
Registry-->>Graph: runner
|
||||
Graph->>Runner: run(node, task, context)
|
||||
Runner->>Primitive: side-effect request
|
||||
Primitive->>Service: execute safe mechanics
|
||||
Service->>Store: persist/audit/transition
|
||||
Service-->>Primitive: primitive result
|
||||
Primitive-->>Runner: outcome/value/contextPatch
|
||||
Runner-->>Graph: graph-native result
|
||||
Graph->>Graph: choose next edge
|
||||
```
|
||||
|
||||
### Target Module Shape
|
||||
|
||||
The exact file layout can adjust during implementation, but the plan assumes these ownership boundaries:
|
||||
|
||||
- `packages/engine/src/workflow-node-runner.ts` defines the runner contract, runner context, registry, and compatibility adapter to `WorkflowNodeHandler`.
|
||||
- `packages/engine/src/workflow-node-runners/` holds built-in runner modules by domain: prompt/script/gate, step review, parse steps, code, notify, merge, PR, and optional group/loop adapters when useful.
|
||||
- `packages/engine/src/workflow-runtime-primitive-provider.ts` or equivalent holds the factory that currently lives in `TaskExecutor.createAuthoritativeWorkflowPrimitives`.
|
||||
- `packages/engine/src/workflow-custom-node-execution.ts` owns the behavior currently concentrated in `TaskExecutor.runGraphCustomNode`.
|
||||
- `packages/engine/src/workflow-review-service.ts` wraps `reviewStep` and workspace/per-step invocation details.
|
||||
- `packages/engine/src/workflow-planning-service.ts` wraps plan and plan-review behavior that currently lives in `TriageProcessor`.
|
||||
|
||||
### Sequencing
|
||||
|
||||
1. Add the runner interface/registry and adapt existing handlers with no behavior change.
|
||||
2. Move pure or nearly pure handlers first: notify, code, parse-steps, gate, merge gate/backoff/manual hold.
|
||||
3. Extract primitive provider and migrate primitive-backed prompt/script/review/merge runners.
|
||||
4. Extract custom node execution service and migrate custom prompt/script/gate behavior.
|
||||
5. Extract review service and connect step-review/review runners.
|
||||
6. Extract planning service and connect planning/plan-review nodes.
|
||||
7. Remove legacy seam/default-handler compatibility where production no longer needs it.
|
||||
8. Update docs, concepts, and tests after each behavior-bearing slice.
|
||||
|
||||
### Assumptions
|
||||
|
||||
- The plan is a focused follow-up, not a replacement for `docs/plans/2026-06-29-002-workflow-node-lifecycle-authority-plan.md`.
|
||||
- No database migration is expected for the first runner extraction slices.
|
||||
- Existing workflow IDs, node IDs, and IR shapes remain stable.
|
||||
- The first implementation can use class instances or object modules as long as the public contract is a well-defined `WorkflowNodeRunner`; the goal is clear ownership and dependency shape, not object-oriented ceremony.
|
||||
- External research is not load-bearing because this is an internal refactor following existing repo architecture and concepts.
|
||||
|
||||
### Alternative Approaches Considered
|
||||
|
||||
- **Keep function handlers and only split files.**
|
||||
This would reduce file size but preserve the same dependency ambiguity: handlers would still mix graph behavior, primitive dispatch, custom execution, and compatibility seams without a contract that plugin runners or tests can target.
|
||||
|
||||
- **Big-bang rewrite of `TaskExecutor`, `TriageProcessor`, and `reviewStep`.**
|
||||
This would produce the cleanest end state on paper but carries too much behavioral risk across worktree, pause, review, merge, and recovery invariants.
|
||||
The plan chooses adapters and characterization-first migration instead.
|
||||
|
||||
- **Allow workflow definitions to contain independent code blocks.**
|
||||
This was rejected because it moves trust, safety, and process supervision problems into workflow authoring.
|
||||
Fusion workflows should stay declarative; engine-owned runners execute known node kinds behind safety boundaries.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
### U1. Add the node runner contract and compatibility registry
|
||||
|
||||
- **Goal:** Introduce a typed runner interface and registry that can resolve node kinds and adapt runners to the existing `WorkflowNodeHandler` call shape without changing behavior.
|
||||
- **Requirements:** R1, R2, R3, R6, R7, R9
|
||||
- **Dependencies:** None
|
||||
- **Files:**
|
||||
- `packages/engine/src/workflow-node-runner.ts` (new)
|
||||
- `packages/engine/src/workflow-graph-executor.ts`
|
||||
- `packages/engine/src/workflow-node-handlers.ts`
|
||||
- `packages/engine/src/__tests__/workflow-node-runner.test.ts` (new)
|
||||
- `packages/engine/src/__tests__/workflow-graph-executor-handlers.test.ts`
|
||||
- **Approach:** Define `WorkflowNodeRunner`, `WorkflowNodeRunnerContext`, and `WorkflowNodeRunnerRegistry`.
|
||||
The registry should support built-in runners, dependency validation, and handler adaptation.
|
||||
Initially register adapters around `createDefaultNodeHandlers` so all existing tests and runtime paths continue to see the same handler behavior.
|
||||
The graph executor should accept either handlers or a registry during migration, with explicit precedence documented in code.
|
||||
- **Patterns to follow:** `createDefaultNodeHandlers` fail-closed behavior in `packages/engine/src/workflow-node-handlers.ts`; plugin extension registry patterns in `packages/core/src/workflow-extension-registry.ts`; existing graph executor dependency injection.
|
||||
- **Test scenarios:**
|
||||
- A registered runner for `notify` receives the same node/task/context shape as a handler and its result routes through the graph.
|
||||
- An unknown non-start/end node kind still fails with the existing "no handler" behavior.
|
||||
- Explicit handler overrides continue to work while the registry is present.
|
||||
- Missing runner dependencies fail closed instead of succeeding.
|
||||
- Plugin/extension runner registration order is deterministic and cannot accidentally override built-ins unless the existing extension contract allows it.
|
||||
- **Verification:** The runner registry can be used in a graph executor unit test without constructing `TaskExecutor`.
|
||||
|
||||
### U2. Move pure and low-side-effect built-in handlers into runner modules
|
||||
|
||||
- **Goal:** Prove the runner pattern by migrating small handlers out of the monolithic handler factory while keeping their behavior byte-equivalent.
|
||||
- **Requirements:** R1, R2, R4, R6, R7
|
||||
- **Dependencies:** U1
|
||||
- **Files:**
|
||||
- `packages/engine/src/workflow-node-runners/gate-runner.ts` (new)
|
||||
- `packages/engine/src/workflow-node-runners/notify-runner.ts` (new)
|
||||
- `packages/engine/src/workflow-node-runners/code-runner.ts` (new)
|
||||
- `packages/engine/src/workflow-node-runners/parse-steps-runner.ts` (new)
|
||||
- `packages/engine/src/workflow-node-handlers.ts`
|
||||
- `packages/engine/src/__tests__/workflow-node-handlers.test.ts`
|
||||
- `packages/engine/src/__tests__/workflow-node-handlers-notify.test.ts`
|
||||
- `packages/engine/src/__tests__/code-node.test.ts`
|
||||
- `packages/engine/src/__tests__/workflow-parse-steps.test.ts`
|
||||
- **Approach:** Move `gate`, `notify`, `code`, and `parse-steps` behavior into runner modules and have `createDefaultNodeHandlers` adapt those runners during the transition.
|
||||
Preserve existing fail-closed sentinel values such as `parse-steps-unwired`, `notify-skipped`, and code-runner failure outputs.
|
||||
Keep executable gates routed through the custom-node service hook until U5 extracts that service.
|
||||
- **Patterns to follow:** Existing `createGateHandler`, `createNotifyHandler`, `createCodeNodeHandler`, and `createParseStepsHandler` behavior.
|
||||
- **Test scenarios:**
|
||||
- Context gates pass and fail on the same `contextKey`/`expect` combinations as before.
|
||||
- Executable gate without a custom-node runner fails closed.
|
||||
- `notify` with no dispatch dependency succeeds as skipped and does not fail the workflow.
|
||||
- `parse-steps` without deps fails with the current unwired value.
|
||||
- Code node runner preserves compile/execute success and failure behavior.
|
||||
- **Verification:** Existing focused node-handler tests pass after imports move to runner modules.
|
||||
|
||||
### U3. Extract the workflow runtime primitive provider from TaskExecutor
|
||||
|
||||
- **Goal:** Move the factory currently in `TaskExecutor.createAuthoritativeWorkflowPrimitives` into a dedicated provider module with explicit substrate dependencies.
|
||||
- **Requirements:** R1, R2, R5, R6, R7
|
||||
- **Dependencies:** U1
|
||||
- **Files:**
|
||||
- `packages/engine/src/workflow-runtime-primitive-provider.ts` (new)
|
||||
- `packages/engine/src/runtime-primitives.ts`
|
||||
- `packages/engine/src/executor.ts`
|
||||
- `packages/engine/src/workflow-authoritative-driver.ts`
|
||||
- `packages/engine/src/__tests__/runtime-primitives.test.ts`
|
||||
- `packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts`
|
||||
- `packages/engine/src/__tests__/workflow-task-runtime.test.ts`
|
||||
- **Approach:** Introduce a provider that receives the narrow dependencies needed for primitives: store access, run context lookup, worktree/session execution callbacks, merge requester, settings, audit/log hooks, and recovery context.
|
||||
Keep `TaskExecutor.createAuthoritativeWorkflowPrimitives` as a compatibility wrapper that delegates to the provider.
|
||||
Do not move implementation behavior and change semantics in the same commit; first preserve output shapes and audit values.
|
||||
- **Patterns to follow:** `WorkflowRuntimePrimitives` in `packages/engine/src/runtime-primitives.ts`; documented workflow-native primitive split in `docs/solutions/architecture-patterns/workflow-native-runtime-primitives.md`.
|
||||
- **Test scenarios:**
|
||||
- Provider-created primitives produce the same planning, execute, review, transition, merge, artifact, and audit results as the existing executor factory for representative fake dependencies.
|
||||
- `prepareWorktree` still tolerates older/minimal stores and does not acquire a second worktree.
|
||||
- `runCodingSession` preserves `implementation-paused` and `implementation-incomplete` values.
|
||||
- `requestMerge` still blocks incomplete implementation steps before invoking the merge requester.
|
||||
- `transitionTask` still uses move semantics when available so notifications fire.
|
||||
- **Verification:** `WorkflowTaskRuntime` can run with primitives created outside a full executor instance in a focused test.
|
||||
|
||||
### U4. Convert primitive-backed prompt/script/review/merge behavior into runners
|
||||
|
||||
- **Goal:** Replace the prompt-like primitive handler branch with node runners for seam-backed prompt/script nodes and merge-attempt nodes.
|
||||
- **Requirements:** R1, R2, R4, R5, R6, R7
|
||||
- **Dependencies:** U1, U3
|
||||
- **Files:**
|
||||
- `packages/engine/src/workflow-node-runners/prompt-seam-runner.ts` (new)
|
||||
- `packages/engine/src/workflow-node-runners/merge-runner.ts` (new)
|
||||
- `packages/engine/src/workflow-node-handlers.ts`
|
||||
- `packages/engine/src/workflow-merge-nodes.ts`
|
||||
- `packages/engine/src/__tests__/workflow-graph-executor-handlers.test.ts`
|
||||
- `packages/engine/src/__tests__/workflow-merge-nodes.test.ts`
|
||||
- `packages/engine/src/__tests__/workflow-graph-executor-retry-coding-workflow.test.ts`
|
||||
- **Approach:** Move seam resolution and primitive dispatch into runner modules.
|
||||
The runner should still stamp `workflow:seam-governing-node-id`, handle `step-execute` foreach context, map primitive outcomes to graph outcomes, and preserve context patching for modified files, summaries, and worktree path.
|
||||
Keep legacy seam adapters in a clearly named compatibility runner only for tests or transitional callers.
|
||||
- **Patterns to follow:** `createPrimitivePromptLikeHandler`, `createPrimitiveStepReviewHandler`, and `runWorkflowMergeAttemptNode`.
|
||||
- **Test scenarios:**
|
||||
- Planning seam calls `runPlanningSession` once and forwards outcome/value/context patch.
|
||||
- Execute seam calls `prepareWorktree` before `runCodingSession` and does not run coding when preparation fails.
|
||||
- Execute seam includes worktree path and modified file/summary context patches as today.
|
||||
- Review seam calls `runReview` with code review input and preserves `in-review` value.
|
||||
- Merge attempt runner preserves merge success, manual-required, transient failure, timeout, and failure values.
|
||||
- Legacy seam compatibility still works only when explicitly selected.
|
||||
- **Verification:** Built-in coding graph parity tests still visit the same logical node IDs and outcomes.
|
||||
|
||||
### U5. Extract custom prompt/script/gate node execution from TaskExecutor
|
||||
|
||||
- **Goal:** Replace `TaskExecutor.runGraphCustomNode` with a dedicated custom node execution service consumed by runners and plugin hooks.
|
||||
- **Requirements:** R1, R4, R5, R6, R7, R9
|
||||
- **Dependencies:** U1, U2, U3
|
||||
- **Files:**
|
||||
- `packages/engine/src/workflow-custom-node-execution.ts` (new)
|
||||
- `packages/engine/src/workflow-node-runners/custom-node-runner.ts` (new)
|
||||
- `packages/engine/src/executor.ts`
|
||||
- `packages/engine/src/plugin-runner.ts`
|
||||
- `packages/engine/src/__tests__/executor-column-agent-custom-node.test.ts`
|
||||
- `packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts`
|
||||
- `packages/engine/src/__tests__/executor-browser-verification.test.ts`
|
||||
- `packages/engine/src/__tests__/workflow-step-integration-cwd.test.ts`
|
||||
- `packages/engine/src/__tests__/ce-workflow-step-conventions.test.ts`
|
||||
- **Approach:** Create a service that owns custom node execution inputs and outputs.
|
||||
Its dependencies should include store, root/workspace context, settings, agent store, message store, skill resolver/session helpers, command runner, approval helpers, column-agent resolver, and task document/artifact tool factories.
|
||||
`TaskExecutor.runGraphCustomNode` becomes a temporary wrapper around the service and is then deleted once callers use the service directly.
|
||||
Preserve all executor kinds and guardrails: `model`, `agent`, `skill`, `cli`, `cli-agent`, await-input, skill await-input sentinel, fast-mode skip, raw CLI approval, column-agent adoption, write-capable worktree guard, workflow tool exposure, and workspace cwd handling.
|
||||
- **Patterns to follow:** Current `runGraphCustomNode` behavior; `createWorkflowAuthoringTools` approval-stripping behavior; column-agent tests.
|
||||
- **Test scenarios:**
|
||||
- Model custom node synthesizes a workflow step and returns the same verdict parsing/output behavior.
|
||||
- Agent custom node adopts agent model/persona and missing agent falls back as today.
|
||||
- Skill node requests both namespaced and bare skill names and preserves workflow-step conventions.
|
||||
- Raw CLI node requires approval unless bypass flags are present from trusted executor lane.
|
||||
- `cli-agent` node routes through the existing task-session orchestration.
|
||||
- Write-capable custom node without a worktree fails with `no-worktree-for-write-node`.
|
||||
- Fast-mode skips custom prompt/script/gate nodes but still enforces await-input and implementation CLI-agent nodes.
|
||||
- Column-agent override/defer precedence remains unchanged.
|
||||
- **Verification:** No production caller needs to invoke a private executor method to run a custom workflow node.
|
||||
|
||||
### U6. Extract workflow review service and migrate review runners
|
||||
|
||||
- **Goal:** Make review-capable nodes depend on a review service rather than direct calls into `reviewer.ts` or executor review tool factories.
|
||||
- **Requirements:** R1, R4, R5, R6, R7, R8
|
||||
- **Dependencies:** U1, U3, U4
|
||||
- **Files:**
|
||||
- `packages/engine/src/workflow-review-service.ts` (new)
|
||||
- `packages/engine/src/reviewer.ts`
|
||||
- `packages/engine/src/executor.ts`
|
||||
- `packages/engine/src/workflow-node-runners/step-review-runner.ts`
|
||||
- `packages/engine/src/__tests__/reviewer.test.ts`
|
||||
- `packages/engine/src/__tests__/reviewer-workspace.test.ts`
|
||||
- `packages/engine/src/__tests__/workflow-step-review.test.ts`
|
||||
- `packages/engine/src/__tests__/workflow-graph-step-rerun.test.ts`
|
||||
- **Approach:** Wrap `reviewStep` behind a service that accepts workflow context, review type, step identity, workspace worktree set, advisory flag, baseline/checkpoint context, settings, and logging callbacks.
|
||||
The first implementation can delegate to `reviewStep`; the important extraction is that workflow runners no longer need executor-specific review wrappers.
|
||||
Preserve workspace behavior where callers loop per acquired sub-repo and aggregate verdicts as a conjunction.
|
||||
- **Patterns to follow:** Current `createReviewStepTool`, `createAuthoritativeWorkflowSeams().stepReview`, `reviewer-workspace` tests, and reviewer unavailable retry contract.
|
||||
- **Test scenarios:**
|
||||
- Step-review runner routes APPROVE, REVISE, RETHINK, and UNAVAILABLE to the same graph values as before.
|
||||
- Advisory split-branch review records verdict context but does not authoritatively mark the step done.
|
||||
- Workspace task review invokes one review per acquired repo and aggregates as today.
|
||||
- Pause/global-pause returns UNAVAILABLE without spawning a reviewer.
|
||||
- Fallback model and stricter retry behavior remain in `reviewStep` or service tests.
|
||||
- **Verification:** Step-review and `fn_review_step` surfaces share the same review service behavior.
|
||||
|
||||
### U7. Extract workflow planning service and connect planning nodes
|
||||
|
||||
- **Goal:** Create a planning service seam so plan/planning/plan-review nodes do not depend on `TriageProcessor` internals.
|
||||
- **Requirements:** R1, R4, R5, R6, R7, R8
|
||||
- **Dependencies:** U1, U3, U4, U6
|
||||
- **Files:**
|
||||
- `packages/engine/src/workflow-planning-service.ts` (new)
|
||||
- `packages/engine/src/triage.ts`
|
||||
- `packages/engine/src/triage-preflight.ts`
|
||||
- `packages/engine/src/workflow-node-runners/planning-runner.ts` (new)
|
||||
- `packages/engine/src/__tests__/triage.test.ts`
|
||||
- `packages/engine/src/__tests__/triage-plan-review-unavailable-retry.test.ts`
|
||||
- `packages/engine/src/__tests__/triage-planning-prompt-single-source.test.ts`
|
||||
- `packages/engine/src/__tests__/workflow-task-runtime.test.ts`
|
||||
- **Approach:** Extract the behavior needed by workflow planning nodes: prompt seed selection, task document read/write, workflow selection tools, memory/research tools, user comments, plan review retry/unavailable handling, and transition from planning to workflow execution.
|
||||
`TriageProcessor` remains the scheduler-facing owner for picking triage tasks, but node execution should call the planning service through primitives or runners.
|
||||
Preserve plan-review-unavailable retry behavior: rerun review/finalization against the existing prompt without cold-starting planning.
|
||||
- **Patterns to follow:** `TriageProcessor` planning session setup, `buildSpecificationPrompt`, plan-review retry tests, and workflow selection routing docs.
|
||||
- **Test scenarios:**
|
||||
- Planning node writes or preserves `PROMPT.md` exactly as current triage planning does for a normal task.
|
||||
- Existing non-empty draft is used for replan/retry instead of cold-starting.
|
||||
- Plan review UNAVAILABLE parks with the current retry state and reruns only review/finalization after backoff.
|
||||
- Plan review REVISE routes to replan and does not start execution.
|
||||
- Workflow selection rules remain unchanged: agents do not reroute current executor tasks without explicit user request.
|
||||
- **Verification:** Triage tests prove scheduler-facing selection still works while workflow runtime tests can exercise planning through the service.
|
||||
|
||||
### U8. Retire legacy seam and private-method test paths where parity exists
|
||||
|
||||
- **Goal:** Remove or quarantine compatibility paths that keep production workflow execution coupled to monolithic executor internals after runner parity is proven.
|
||||
- **Requirements:** R1, R2, R4, R7, R9, R10
|
||||
- **Dependencies:** U2, U3, U4, U5, U6, U7
|
||||
- **Files:**
|
||||
- `packages/engine/src/workflow-node-handlers.ts`
|
||||
- `packages/engine/src/workflow-authoritative-driver.ts`
|
||||
- `packages/engine/src/executor.ts`
|
||||
- `packages/engine/src/workflow-task-runtime.ts`
|
||||
- `packages/engine/src/__tests__/workflow-graph-executor-parity.test.ts`
|
||||
- `packages/engine/src/__tests__/workflow-node-handlers.test.ts`
|
||||
- `packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts`
|
||||
- **Approach:** Once runner and service tests cover the behavior, remove production reliance on `WorkflowLegacySeams` and private executor methods.
|
||||
Keep an explicitly named compatibility adapter only for old tests or old runtime entry points that cannot yet be migrated.
|
||||
Replace private-method tests with graph/runtime tests or service tests where possible.
|
||||
- **Patterns to follow:** Existing fail-closed compatibility posture and workflow-native cutover docs.
|
||||
- **Test scenarios:**
|
||||
- Production authoritative workflow execution does not construct legacy seams.
|
||||
- Tests that need old minimal fakes must opt into the compatibility adapter explicitly.
|
||||
- Built-in workflow parity tests still pass for default coding, stepwise coding, quick fix, review-heavy, marketing, design, lead generation, and compound engineering where plugin-gated.
|
||||
- Custom workflow prompt/script/gate parity remains intact through runner/service paths.
|
||||
- **Verification:** Grepping production workflow runtime paths shows no dependency on `createAuthoritativeWorkflowSeams` except compatibility tests or adapters.
|
||||
|
||||
### U9. Update documentation, concepts, and release metadata
|
||||
|
||||
- **Goal:** Make the new node runner boundary discoverable for future implementers and operators.
|
||||
- **Requirements:** R3, R9, R10
|
||||
- **Dependencies:** U1-U8 as applicable
|
||||
- **Files:**
|
||||
- `docs/workflow-steps.md`
|
||||
- `docs/architecture.md`
|
||||
- `CONCEPTS.md`
|
||||
- `docs/solutions/architecture-patterns/workflow-native-runtime-primitives.md`
|
||||
- `.changeset/*.md` when behavior changes published `@runfusion/fusion`
|
||||
- `packages/cli/src/__tests__/workflow-docs-current.test.ts`
|
||||
- **Approach:** Document the four-way split: workflow IR policy, node runners, runtime primitives, and engine substrate.
|
||||
Update concepts only for durable vocabulary such as Workflow Node Runner or Node Runner Registry.
|
||||
Add a changeset only for user-visible behavior changes, not for internal refactor-only slices.
|
||||
- **Patterns to follow:** Existing Concepts entries for Workflow Runtime, Runtime Primitive, Built-in Lifecycle Node, and Workflow Extension.
|
||||
- **Test scenarios:**
|
||||
- CLI docs-current tests pass after doc updates.
|
||||
- Concepts define durable domain vocabulary without turning into implementation spec.
|
||||
- Changeset format passes if a changeset is needed.
|
||||
- **Verification:** A future contributor can identify where to add a new workflow node behavior without reading `executor.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Verification Contract
|
||||
|
||||
### Focused Test Gates
|
||||
|
||||
| Gate | Purpose | Applicability |
|
||||
|---|---|---|
|
||||
| `pnpm --filter @fusion/engine exec vitest run src/__tests__/workflow-node-runner.test.ts src/__tests__/workflow-graph-executor-handlers.test.ts --silent=passed-only --reporter=dot` | Proves the registry/adapter and graph dispatch behavior. | U1-U4 |
|
||||
| `pnpm --filter @fusion/engine exec vitest run src/__tests__/workflow-node-handlers.test.ts src/__tests__/workflow-node-handlers-notify.test.ts src/__tests__/workflow-parse-steps.test.ts src/__tests__/code-node.test.ts --silent=passed-only --reporter=dot` | Proves migrated built-in node behavior remains equivalent. | U2 |
|
||||
| `pnpm --filter @fusion/engine exec vitest run src/__tests__/runtime-primitives.test.ts src/__tests__/workflow-task-runtime.test.ts src/__tests__/executor-fast-mode-workflows.test.ts --silent=passed-only --reporter=dot` | Proves primitive provider extraction and runtime integration. | U3-U5 |
|
||||
| `pnpm --filter @fusion/engine exec vitest run src/__tests__/executor-column-agent-custom-node.test.ts src/__tests__/executor-browser-verification.test.ts src/__tests__/workflow-step-integration-cwd.test.ts src/__tests__/ce-workflow-step-conventions.test.ts --silent=passed-only --reporter=dot` | Proves custom-node execution behavior. | U5 |
|
||||
| `pnpm --filter @fusion/engine exec vitest run src/__tests__/reviewer.test.ts src/__tests__/reviewer-workspace.test.ts src/__tests__/workflow-step-review.test.ts --silent=passed-only --reporter=dot` | Proves review service and step-review parity. | U6 |
|
||||
| `pnpm --filter @fusion/engine exec vitest run src/__tests__/triage.test.ts src/__tests__/triage-plan-review-unavailable-retry.test.ts src/__tests__/triage-planning-prompt-single-source.test.ts --silent=passed-only --reporter=dot` | Proves planning service extraction. | U7 |
|
||||
|
||||
### Package Gates
|
||||
|
||||
| Gate | Done signal |
|
||||
|---|---|
|
||||
| `pnpm --filter @fusion/engine exec tsc --noEmit --pretty false` | Engine type boundaries are valid after each extraction slice. |
|
||||
| `pnpm --filter @fusion/core exec tsc --noEmit --pretty false` | Core workflow extension and IR type contracts remain compatible if touched. |
|
||||
| `pnpm check:changesets` | Required when a behavior-bearing slice adds a changeset for published `@runfusion/fusion`. |
|
||||
| `pnpm verify:fast` | Final non-test verification path after the full refactor plan is complete. |
|
||||
|
||||
### Behavioral Scenarios
|
||||
|
||||
- Default coding workflow runs planning, execute, optional gates, summary/review, and merge through runner-backed dispatch with no behavior drift.
|
||||
- Custom prompt/script/gate workflow nodes run through the custom node service and preserve executor modes, approval behavior, and fast-mode behavior.
|
||||
- Stepwise workflow foreach execution preserves step-execute, step-review, RETHINK reset, advisory split review, and workspace worktree isolation.
|
||||
- Plugin workflow extensions continue registering and executing without importing executor private methods.
|
||||
- Engine pause, hard cancel, and restart still resume or park the same nodes as before.
|
||||
- Missing runner dependency fails closed with a clear outcome instead of silently passing.
|
||||
|
||||
---
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
This refactor affects Fusion's core orchestration posture.
|
||||
It should make workflow behavior easier to add and test, but it touches high-risk surfaces: agent sessions, worktree mutation, review gates, merge requests, pause/retry recovery, and plugin extension points.
|
||||
Operators should see no behavior change during early slices.
|
||||
Developers should gradually stop adding workflow behavior to `executor.ts` and instead choose the correct runner, primitive provider, or substrate service.
|
||||
|
||||
---
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
- **RISK-1. Behavior drift hidden by refactor shape.**
|
||||
Mitigation: characterize each behavior through graph/runtime tests before moving it, and keep private-method tests temporary only.
|
||||
|
||||
- **RISK-2. Premature class hierarchy adds indirection without reducing coupling.**
|
||||
Mitigation: keep the contract small and dependency-driven; object modules are acceptable when classes add no value.
|
||||
|
||||
- **RISK-3. Custom node extraction misses rare executor modes.**
|
||||
Mitigation: U5 explicitly enumerates model, agent, skill, CLI, CLI-agent, await-input, fast-mode, approval, column-agent, write-capable, workspace, task document, and artifact behavior.
|
||||
|
||||
- **RISK-4. Review/planning services accidentally bypass pause or semaphore rules.**
|
||||
Mitigation: preserve low-level `reviewStep` and triage service behavior first; expose services as wrappers before internal cleanup.
|
||||
|
||||
- **RISK-5. Plugin handler precedence changes.**
|
||||
Mitigation: registry tests must prove deterministic ordering and existing extension override rules.
|
||||
|
||||
- **RISK-6. Existing lifecycle-authority work overlaps.**
|
||||
Mitigation: treat this plan as the structural extraction layer; behavior-policy changes stay in `docs/plans/2026-06-29-002-workflow-node-lifecycle-authority-plan.md`.
|
||||
|
||||
---
|
||||
|
||||
## Documentation / Operational Notes
|
||||
|
||||
- Update docs as runner slices land, not only at the end, so future contributors do not add new workflow behavior to deprecated monolith seams.
|
||||
- Keep FNXC requirement comments near new runner contracts and extracted services when implementing; comments should explain why the boundary exists and what invariants stay outside node runners.
|
||||
- Use repo-relative paths in docs and tests.
|
||||
- Do not add a changeset for the plan itself or internal refactor-only commits.
|
||||
|
||||
---
|
||||
|
||||
## Sources & Research
|
||||
|
||||
- `docs/workflow-steps.md` documents `WorkflowGraphExecutor` as the task lifecycle runtime and `WorkflowRuntimePrimitives` as the side-effect boundary.
|
||||
- `docs/solutions/architecture-patterns/workflow-native-runtime-primitives.md` defines the workflow policy / runtime primitive / engine substrate split this plan extends.
|
||||
- `CONCEPTS.md` defines Workflow Runtime, Runtime Primitive, Built-in Lifecycle Node, Recovery Event, and Workflow Extension.
|
||||
- `docs/plans/2026-06-29-002-workflow-node-lifecycle-authority-plan.md` is the adjacent lifecycle-policy plan this plan intentionally does not replace.
|
||||
- `packages/engine/src/workflow-node-handlers.ts` contains the current function handler factory and fail-closed behavior.
|
||||
- `packages/engine/src/workflow-graph-executor.ts` contains graph traversal and handler dispatch.
|
||||
- `packages/engine/src/executor.ts` currently owns primitive creation, custom node execution, task session tools, review/merge handoffs, and many substrate invariants.
|
||||
- `packages/engine/src/triage.ts` currently owns planning-session and plan-review orchestration.
|
||||
- `packages/engine/src/reviewer.ts` currently owns reviewer subprocess behavior and verdict recovery.
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- A `WorkflowNodeRunner` contract and registry exist and are used by workflow graph execution.
|
||||
- Existing built-in node kinds are represented by runner modules or explicit compatibility adapters.
|
||||
- Runtime primitive creation no longer requires callers to know `TaskExecutor` internals.
|
||||
- Custom prompt/script/gate node execution is owned by a dedicated service, with all executor modes and guards preserved.
|
||||
- Review and planning workflow nodes call dedicated services or primitive adapters, not triage/reviewer monolith internals directly.
|
||||
- Production authoritative workflow execution no longer depends on legacy seams except a clearly named compatibility adapter.
|
||||
- Focused tests cover runner dispatch, primitive provider behavior, custom node execution, review service behavior, planning service behavior, and graph parity.
|
||||
- Safety invariants remain in engine substrate services and are not moved into workflow IR or plugins.
|
||||
- Docs and concepts describe the final boundary.
|
||||
- Abandoned transitional adapters and private-method tests are removed once parity paths exist.
|
||||
@@ -200,9 +200,9 @@ Out of scope for v1:
|
||||
|
||||
### Workflow Runtime
|
||||
|
||||
The workflow runtime is the authoritative execution path for task lifecycle work. `WorkflowGraphExecutor` owns graph traversal and routing; node handlers call runtime primitives supplied by `TaskExecutor` for side-effecting operations such as planning, coding sessions, review, step execution/reset, merge requests, transitions, and audit.
|
||||
The workflow runtime is the authoritative execution path for task lifecycle work. `WorkflowGraphExecutor` owns graph traversal and routing; workflow node runners own node-kind behavior; runtime primitives and runtime services perform side-effecting operations such as planning, coding sessions, custom-node execution, review, step execution/reset, merge requests, transitions, and audit.
|
||||
|
||||
The engine remains the substrate for scheduler dispatch, routing claims, persistence, concurrency limits, process supervision, storage, and audit plumbing. Lifecycle policy belongs in built-in or custom workflows.
|
||||
The engine remains the substrate for scheduler dispatch, routing claims, persistence, concurrency limits, process supervision, storage, and audit plumbing. Lifecycle policy belongs in built-in or custom workflows, not in hidden executor/reviewer/triage branches.
|
||||
|
||||
The default built-in catalog entry `builtin:coding` is backed by a Stepwise-derived graph with two default-on, toggleable review gates: `plan-review` before execution and `code-review` at the end of implementation. It is the resolver/runtime fallback for tasks with no workflow selection or an explicit default selection. Missing explicit custom selections fail closed as workflow-resolution failures, and corrupt or invalid resolved IR fails with `invalid-ir` instead of silently running the default or a legacy workflow. The built-in IR parses planned steps, executes them sequentially without per-step review, then routes optional quality gates into the merge region:
|
||||
|
||||
|
||||
@@ -97,7 +97,6 @@ describe("fast mode workflow/runtime invariants", () => {
|
||||
});
|
||||
|
||||
const result = await runner.run(task({ id: "FN-6226", executionMode: "fast" }), { experimentalFeatures: { workflowGraphExecutor: true } });
|
||||
|
||||
expect(result.disposition).toBe("completed");
|
||||
expect(result.visitedNodeIds).toEqual(["start", "custom-review", "custom-gate"]);
|
||||
expect(executeStep).not.toHaveBeenCalled();
|
||||
@@ -195,6 +194,10 @@ describe("fast mode workflow/runtime invariants", () => {
|
||||
expect(calls).toContain("parse");
|
||||
expect(calls).toContain("step-execute:0");
|
||||
expect(calls).not.toContain("legacy-execute");
|
||||
/*
|
||||
FNXC:WorkflowFastMode 2026-07-01-00:00:
|
||||
The default built-in now resolves to the stepwise final-review workflow. In raw fast-mode compatibility runs, default-on review groups are skipped as custom nodes and the legacy review seam is not invoked; the merge seam remains the lifecycle suffix assertion.
|
||||
*/
|
||||
expect(seams.review).not.toHaveBeenCalled();
|
||||
expect(seams.merge).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { markSideEffectsStarted, primitiveNodeContext } from "../runtime-primitives.js";
|
||||
import { createWorkflowRuntimePrimitiveProvider } from "../workflow-runtime-primitive-provider.js";
|
||||
|
||||
describe("runtime primitives", () => {
|
||||
it("creates a workflow primitive context from a run and node", () => {
|
||||
@@ -54,4 +55,39 @@ describe("runtime primitives", () => {
|
||||
});
|
||||
expect(ctx.run.sideEffectsStarted).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates runtime primitives through a provider boundary", async () => {
|
||||
const provider = createWorkflowRuntimePrimitiveProvider((settings) => ({
|
||||
prepareWorktree: async () => ({
|
||||
outcome: "success" as const,
|
||||
value: settings.experimentalFeatures?.workflowGraphExecutor ? "enabled" : "disabled",
|
||||
data: { worktreePath: "/tmp/worktree" },
|
||||
}),
|
||||
readArtifact: async () => undefined,
|
||||
writeArtifact: async (_ctx, _task, key) => ({ outcome: "success" as const, data: { key } }),
|
||||
runPlanningSession: async () => ({ outcome: "success" as const, data: { approved: true, artifactKeys: [] } }),
|
||||
runCodingSession: async () => ({ outcome: "success" as const, data: { taskDone: true, modifiedFiles: [] } }),
|
||||
runTaskStep: async () => ({ outcome: "success" as const }),
|
||||
resetTaskStep: async () => ({ ok: true }),
|
||||
runReview: async () => ({ outcome: "success" as const, data: { verdict: "APPROVE" as const } }),
|
||||
runVerification: async () => ({ outcome: "success" as const, data: { verdict: "skipped" as const } }),
|
||||
updateSteps: async (_ctx, _task, steps) => ({ outcome: "success" as const, data: { count: steps.length } }),
|
||||
transitionTask: async (_ctx, _task, input) => ({ outcome: "success" as const, value: input.reason }),
|
||||
requestMerge: async () => ({ outcome: "success" as const, data: { status: "merged" as const } }),
|
||||
abortRun: async () => ({ outcome: "success" as const }),
|
||||
audit: () => undefined,
|
||||
}));
|
||||
|
||||
const primitives = provider.create({ experimentalFeatures: { workflowGraphExecutor: true } } as never);
|
||||
const result = await primitives.prepareWorktree(
|
||||
primitiveNodeContext({ runId: "run-1", taskId: "FN-1", workflowId: "coding" }, { id: "execute", kind: "prompt" }),
|
||||
{ id: "FN-1" } as never,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
outcome: "success",
|
||||
value: "enabled",
|
||||
data: { worktreePath: "/tmp/worktree" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Settings, TaskDetail, WorkflowColumnAgent, WorkflowIrNode } from "@fusion/core";
|
||||
|
||||
import { WorkflowCustomNodeExecutionService } from "../workflow-custom-node-execution.js";
|
||||
|
||||
describe("WorkflowCustomNodeExecutionService", () => {
|
||||
it("adapts a custom-node executor into the graph runner contract with column binding", async () => {
|
||||
const binding = { agentId: "agent-reviewer", mode: "override" } as WorkflowColumnAgent;
|
||||
const execute = vi.fn(async () => ({ outcome: "success" as const, value: "ran" }));
|
||||
const service = new WorkflowCustomNodeExecutionService({
|
||||
execute,
|
||||
resolveColumnBinding: (nodeId) => (nodeId === "review-node" ? binding : undefined),
|
||||
});
|
||||
const settings = { experimentalFeatures: { workflowGraphExecutor: true } } as Settings;
|
||||
const node = { id: "review-node", kind: "prompt", config: { prompt: "review" } } as WorkflowIrNode;
|
||||
const task = { id: "FN-7301" } as TaskDetail;
|
||||
const context = { "workflow:optionalGroupActive": "review-node" };
|
||||
|
||||
const result = await service.runner(settings)(node, task, context);
|
||||
|
||||
expect(result).toEqual({ outcome: "success", value: "ran" });
|
||||
expect(execute).toHaveBeenCalledWith(node, task, settings, binding, context);
|
||||
});
|
||||
});
|
||||
105
packages/engine/src/__tests__/workflow-node-runner.test.ts
Normal file
105
packages/engine/src/__tests__/workflow-node-runner.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { TaskDetail, WorkflowIr, WorkflowIrNode } from "@fusion/core";
|
||||
|
||||
import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
|
||||
import {
|
||||
WorkflowNodeRunnerRegistry,
|
||||
handlerBackedRunner,
|
||||
type WorkflowNodeRunner,
|
||||
} from "../workflow-node-runner.js";
|
||||
|
||||
const task = { id: "FN-7300" } as TaskDetail;
|
||||
|
||||
function settingsOn() {
|
||||
return { experimentalFeatures: { workflowGraphExecutor: true } };
|
||||
}
|
||||
|
||||
function linearIr(node: WorkflowIrNode): WorkflowIr {
|
||||
return {
|
||||
version: "v1",
|
||||
name: "runner-linear",
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
node,
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: node.id },
|
||||
{ from: node.id, to: "end", condition: "success" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowNodeRunnerRegistry", () => {
|
||||
it("adapts a registered runner into graph execution", async () => {
|
||||
const run = vi.fn(async () => ({ outcome: "success" as const, value: "sent" }));
|
||||
const registry = new WorkflowNodeRunnerRegistry({
|
||||
runners: [{
|
||||
kind: "notify",
|
||||
run,
|
||||
} satisfies WorkflowNodeRunner],
|
||||
});
|
||||
|
||||
const notifyNode: WorkflowIrNode = { id: "notify-team", kind: "notify" };
|
||||
const executor = new WorkflowGraphExecutor({ runnerRegistry: registry });
|
||||
const result = await executor.run(task, settingsOn(), linearIr(notifyNode));
|
||||
|
||||
expect(result.outcome).toBe("success");
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
notifyNode,
|
||||
expect.objectContaining({
|
||||
task,
|
||||
context: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("lets explicit handler overrides win over registered runners", async () => {
|
||||
const runner = vi.fn(async () => ({ outcome: "success" as const, value: "runner" }));
|
||||
const handler = vi.fn(async () => ({ outcome: "failure" as const, value: "handler" }));
|
||||
const registry = new WorkflowNodeRunnerRegistry({
|
||||
runners: [{
|
||||
kind: "prompt",
|
||||
run: runner,
|
||||
} satisfies WorkflowNodeRunner],
|
||||
});
|
||||
|
||||
const executor = new WorkflowGraphExecutor({
|
||||
runnerRegistry: registry,
|
||||
handlers: { prompt: handler },
|
||||
});
|
||||
const result = await executor.run(task, settingsOn(), linearIr({ id: "p", kind: "prompt" }));
|
||||
|
||||
expect(result.outcome).toBe("failure");
|
||||
expect(result.visitedNodeIds).toEqual(["start", "p"]);
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
expect(runner).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves default fail-closed behavior when no runner dependency is registered", async () => {
|
||||
const registry = new WorkflowNodeRunnerRegistry();
|
||||
const executor = new WorkflowGraphExecutor({ runnerRegistry: registry });
|
||||
const result = await executor.run(task, settingsOn(), linearIr({ id: "parse", kind: "parse-steps" }));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
outcome: "failure",
|
||||
visitedNodeIds: ["start", "parse"],
|
||||
});
|
||||
expect(result.context["node:parse:value"]).toBe("parse-steps-unwired");
|
||||
});
|
||||
|
||||
it("adapts existing handlers into runners for migration compatibility", async () => {
|
||||
const handler = vi.fn(async () => ({ outcome: "success" as const, value: "handler-backed" }));
|
||||
const registry = new WorkflowNodeRunnerRegistry({
|
||||
runners: [handlerBackedRunner("script", handler)],
|
||||
});
|
||||
const scriptNode: WorkflowIrNode = { id: "script", kind: "script" };
|
||||
|
||||
const executor = new WorkflowGraphExecutor({ runnerRegistry: registry });
|
||||
const result = await executor.run(task, settingsOn(), linearIr(scriptNode));
|
||||
|
||||
expect(result.outcome).toBe("success");
|
||||
expect(result.context["node:script:value"]).toBe("handler-backed");
|
||||
expect(handler).toHaveBeenCalledWith(scriptNode, expect.objectContaining({ task }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { primitiveNodeContext } from "../runtime-primitives.js";
|
||||
import { WorkflowPlanningService } from "../workflow-planning-service.js";
|
||||
|
||||
describe("WorkflowPlanningService", () => {
|
||||
it("preserves the graph pre-specified planning result", async () => {
|
||||
const service = new WorkflowPlanningService();
|
||||
|
||||
const result = await service.runPlanningSession(
|
||||
primitiveNodeContext(
|
||||
{ runId: "run-1", taskId: "FN-7303", workflowId: "builtin:coding" },
|
||||
{ id: "planning", kind: "prompt" },
|
||||
),
|
||||
{ id: "FN-7303" } as never,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
outcome: "success",
|
||||
value: "pre-specified",
|
||||
data: {
|
||||
approved: true,
|
||||
artifactKeys: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { WorkflowReviewService } from "../workflow-review-service.js";
|
||||
|
||||
describe("WorkflowReviewService", () => {
|
||||
it("forwards single-cwd step review input through the injected invoker", async () => {
|
||||
const invoke = vi.fn(async () => ({
|
||||
verdict: "APPROVE" as const,
|
||||
review: "looks good",
|
||||
summary: "approved",
|
||||
}));
|
||||
const service = new WorkflowReviewService(invoke);
|
||||
const input = {
|
||||
cwd: "/tmp/worktree",
|
||||
taskId: "FN-7302",
|
||||
stepIndex: 1,
|
||||
stepName: "Implement",
|
||||
type: "code" as const,
|
||||
promptContent: "# Task",
|
||||
baselineSha: "abc123",
|
||||
};
|
||||
|
||||
const result = await service.reviewStep(input);
|
||||
|
||||
expect(result.verdict).toBe("APPROVE");
|
||||
expect(invoke).toHaveBeenCalledWith(input);
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,10 @@ import type {
|
||||
WorkflowPrimitiveContext,
|
||||
WorkflowRuntimePrimitives,
|
||||
} from "./runtime-primitives.js";
|
||||
import { createWorkflowRuntimePrimitiveProvider } from "./workflow-runtime-primitive-provider.js";
|
||||
import { WorkflowCustomNodeExecutionService } from "./workflow-custom-node-execution.js";
|
||||
import { WorkflowReviewService } from "./workflow-review-service.js";
|
||||
import { WorkflowPlanningService } from "./workflow-planning-service.js";
|
||||
import {
|
||||
ApprovalRequestStore,
|
||||
buildExecutionMemoryInstructions,
|
||||
@@ -4702,6 +4706,11 @@ export class TaskExecutor {
|
||||
|
||||
graphAbortController = new AbortController();
|
||||
this.activeWorkflowGraphAbortControllers.set(task.id, graphAbortController);
|
||||
const customNodeExecution = new WorkflowCustomNodeExecutionService({
|
||||
execute: (node, nodeTask, nodeSettings, columnBinding, context) =>
|
||||
this.runGraphCustomNode(node, nodeTask, nodeSettings, columnBinding, context),
|
||||
resolveColumnBinding: resolveBindingForNode,
|
||||
});
|
||||
const runner = new WorkflowGraphTaskRunner({
|
||||
store: {
|
||||
...this.store,
|
||||
@@ -4717,8 +4726,7 @@ export class TaskExecutor {
|
||||
seams: this.createAuthoritativeWorkflowSeams(settings),
|
||||
prepareNodeExecution: (node, nodeTask, requirement) =>
|
||||
this.prepareGraphNodeExecution(node, nodeTask, settings, requirement),
|
||||
runCustomNode: (node, nodeTask, context) =>
|
||||
this.runGraphCustomNode(node, nodeTask, settings, resolveBindingForNode(node.id), context),
|
||||
runCustomNode: customNodeExecution.runner(settings),
|
||||
publishTaskProjection: async (taskId, patch) => {
|
||||
await this.store.updateTaskAtomic(taskId, (liveTask) => {
|
||||
const update: Parameters<TaskStore["updateTask"]>[1] = {};
|
||||
@@ -5683,6 +5691,12 @@ export class TaskExecutor {
|
||||
/** Public authoritative-driver seam factory: exposes the same real lifecycle
|
||||
* seams the internal graph runner uses, without changing legacy behavior. */
|
||||
public createAuthoritativeWorkflowPrimitives(settings: Settings): WorkflowRuntimePrimitives {
|
||||
return createWorkflowRuntimePrimitiveProvider((providerSettings) =>
|
||||
this.createAuthoritativeWorkflowPrimitivesFromExecutor(providerSettings),
|
||||
).create(settings);
|
||||
}
|
||||
|
||||
private createAuthoritativeWorkflowPrimitivesFromExecutor(settings: Settings): WorkflowRuntimePrimitives {
|
||||
const logAudit = async (taskId: string | undefined, input: AuditPrimitiveInput): Promise<void> => {
|
||||
if (!taskId) return;
|
||||
try {
|
||||
@@ -5691,6 +5705,7 @@ export class TaskExecutor {
|
||||
// Audit is diagnostic-only and must not affect workflow execution.
|
||||
}
|
||||
};
|
||||
const planningService = new WorkflowPlanningService();
|
||||
|
||||
return {
|
||||
prepareWorktree: async (_ctx, task) => {
|
||||
@@ -5727,10 +5742,7 @@ export class TaskExecutor {
|
||||
await writer.call(this.store, task.id, key, content);
|
||||
return { outcome: "success", value: "artifact-written", data: { key } };
|
||||
},
|
||||
runPlanningSession: async () => ({ outcome: "success", value: "pre-specified", data: {
|
||||
approved: true,
|
||||
artifactKeys: [],
|
||||
} }),
|
||||
runPlanningSession: (ctx, task) => planningService.runPlanningSession(ctx, task),
|
||||
runCodingSession: async (ctx, task, prepared) => {
|
||||
const governingNodeId = ctx.node.context?.[SEAM_GOVERNING_NODE_CONTEXT_KEY];
|
||||
if (typeof governingNodeId === "string") {
|
||||
@@ -6371,18 +6383,19 @@ export class TaskExecutor {
|
||||
// `worktreePath`; in workspace mode that is the browse-only non-git root, so we instead spawn
|
||||
// one reviewer per acquired sub-repo (cwd = repo.worktreePath) via reviewWorkspacePerRepo and
|
||||
// aggregate as a conjunction. `invokeReviewerForCwd` is the per-cwd reviewStep call both modes share.
|
||||
const reviewService = new WorkflowReviewService();
|
||||
const invokeReviewerForCwd = (cwd: string) =>
|
||||
reviewStep(
|
||||
reviewService.reviewStep({
|
||||
cwd,
|
||||
seamTask.id,
|
||||
taskId: seamTask.id,
|
||||
stepIndex,
|
||||
stepName,
|
||||
config.type,
|
||||
type: config.type,
|
||||
promptContent,
|
||||
// Code reviews diff against the per-step baseline captured at
|
||||
// step-execute; plan reviews pass no baseline (advisory).
|
||||
config.type === "code" ? active.baselineSha : undefined,
|
||||
{
|
||||
baselineSha: config.type === "code" ? active.baselineSha : undefined,
|
||||
options: {
|
||||
defaultProvider: settings.defaultProvider,
|
||||
defaultModelId: settings.defaultModelId,
|
||||
fallbackProvider: settings.fallbackProvider,
|
||||
@@ -6409,7 +6422,7 @@ export class TaskExecutor {
|
||||
onSessionCreated: (s) => this.registerSubagentSession(seamTask.id, s),
|
||||
onSessionEnded: (s) => this.unregisterSubagentSession(seamTask.id, s),
|
||||
},
|
||||
);
|
||||
});
|
||||
const runForCwd = (cwd: string) => {
|
||||
const invoke = () => invokeReviewerForCwd(cwd);
|
||||
return sem ? sem.runNested(invoke) : invoke();
|
||||
|
||||
@@ -84,6 +84,29 @@ export {
|
||||
type CodeNodeRunner,
|
||||
type DefaultNodeHandlerDeps,
|
||||
} from "./workflow-node-handlers.js";
|
||||
export {
|
||||
WorkflowNodeRunnerRegistry,
|
||||
handlerBackedRunner,
|
||||
type WorkflowNodeRunner,
|
||||
type WorkflowNodeRunnerContext,
|
||||
type WorkflowNodeRunnerKind,
|
||||
} from "./workflow-node-runner.js";
|
||||
export {
|
||||
createWorkflowRuntimePrimitiveProvider,
|
||||
CallbackWorkflowRuntimePrimitiveProvider,
|
||||
type WorkflowRuntimePrimitiveProvider,
|
||||
type WorkflowRuntimePrimitiveFactory,
|
||||
} from "./workflow-runtime-primitive-provider.js";
|
||||
export {
|
||||
WorkflowCustomNodeExecutionService,
|
||||
type WorkflowCustomNodeExecutionServiceDeps,
|
||||
} from "./workflow-custom-node-execution.js";
|
||||
export {
|
||||
WorkflowReviewService,
|
||||
type WorkflowReviewStepInput,
|
||||
type WorkflowReviewStepInvoker,
|
||||
} from "./workflow-review-service.js";
|
||||
export { WorkflowPlanningService } from "./workflow-planning-service.js";
|
||||
export {
|
||||
markSideEffectsStarted,
|
||||
primitiveNodeContext,
|
||||
|
||||
37
packages/engine/src/workflow-custom-node-execution.ts
Normal file
37
packages/engine/src/workflow-custom-node-execution.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Settings, TaskDetail, WorkflowColumnAgent, WorkflowIrNode } from "@fusion/core";
|
||||
|
||||
import type { WorkflowNodeResult } from "./workflow-graph-executor.js";
|
||||
import type { WorkflowCustomNodeRunner } from "./workflow-node-handlers.js";
|
||||
|
||||
export interface WorkflowCustomNodeExecutionServiceDeps {
|
||||
execute: (
|
||||
node: WorkflowIrNode,
|
||||
task: TaskDetail,
|
||||
settings: Settings,
|
||||
columnBinding?: WorkflowColumnAgent,
|
||||
context?: Record<string, unknown>,
|
||||
) => Promise<WorkflowNodeResult>;
|
||||
resolveColumnBinding?: (nodeId: string) => WorkflowColumnAgent | undefined;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowCustomNodes 2026-07-01-00:00:
|
||||
Custom prompt/script/gate execution is exposed as a service boundary so graph runners and plugin hooks can depend on a typed node-execution service instead of a private TaskExecutor method. The service delegates during migration; later slices can move executor modes and approval/worktree guards behind this contract.
|
||||
|
||||
FNXC:WorkflowCustomNodes 2026-07-01-00:00:
|
||||
The custom-node service must preserve graph context while abstracting executor internals because optional-group activation and workflow metadata are carried through the runner context, not only through task/settings inputs.
|
||||
*/
|
||||
export class WorkflowCustomNodeExecutionService {
|
||||
public constructor(private readonly deps: WorkflowCustomNodeExecutionServiceDeps) {}
|
||||
|
||||
public runner(settings: Settings): WorkflowCustomNodeRunner {
|
||||
return (node, task, context) =>
|
||||
this.deps.execute(
|
||||
node,
|
||||
task,
|
||||
settings,
|
||||
this.deps.resolveColumnBinding?.(node.id),
|
||||
context,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
type WorkflowStepInstancePersistence,
|
||||
} from "./workflow-graph-foreach.js";
|
||||
import { runLoop, runOptionalGroup } from "./workflow-graph-loop.js";
|
||||
import type { WorkflowNodeRunnerRegistry } from "./workflow-node-runner.js";
|
||||
|
||||
export type WorkflowNodeOutcome = "success" | "failure";
|
||||
|
||||
@@ -94,6 +95,11 @@ export interface WorkflowNodePreparationRequirement {
|
||||
|
||||
export interface WorkflowGraphExecutorDeps {
|
||||
handlers?: Partial<Record<WorkflowIrNode["kind"], WorkflowNodeHandler>>;
|
||||
/*
|
||||
* FNXC:WorkflowNodeRunners 2026-07-01-00:00:
|
||||
* Node runners are the new ownership boundary for workflow node behavior. During migration the graph accepts a registry and adapts it into handlers, while explicit handlers remain the highest-precedence test/plugin override so existing graph semantics do not drift.
|
||||
*/
|
||||
runnerRegistry?: WorkflowNodeRunnerRegistry;
|
||||
/** Workflow-native runtime primitives. When present, default nodes call these
|
||||
* directly instead of legacy executor/reviewer/merge seams. */
|
||||
primitives?: WorkflowRuntimePrimitives;
|
||||
@@ -326,6 +332,7 @@ export class WorkflowGraphExecutor {
|
||||
notifyDispatch: deps.notifyDispatch,
|
||||
prNodes: deps.prNodes,
|
||||
}),
|
||||
...(deps.runnerRegistry?.toHandlers() ?? {}),
|
||||
...(deps.handlers ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Settings, TaskDetail, WorkflowDefinition, WorkflowIr, WorkflowStepResult } from "@fusion/core";
|
||||
import type { Settings, TaskDetail, TaskStep, WorkflowDefinition, WorkflowIr, WorkflowStepResult } from "@fusion/core";
|
||||
import {
|
||||
compileWorkflowToSteps,
|
||||
getBuiltinWorkflow,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
WORKFLOW_INTERRUPTED_NODE_ABORT_KIND_CONTEXT_KEY,
|
||||
WORKFLOW_INTERRUPTED_NODE_ID_CONTEXT_KEY,
|
||||
WORKFLOW_NODE_ENGINE_PAUSE_ABORT_KIND,
|
||||
WORKFLOW_OPTIONAL_GROUP_CONTEXT_KEY,
|
||||
WorkflowGraphExecutor,
|
||||
type WorkflowGraphExecutorDeps,
|
||||
type WorkflowNodePreparationRequirement,
|
||||
@@ -249,6 +250,23 @@ export class WorkflowGraphTaskRunner {
|
||||
: {}),
|
||||
};
|
||||
const wrappedRunCustomNode: WorkflowCustomNodeRunner = (node, t, c) => {
|
||||
if (!this.deps.primitives && (t as { executionMode?: unknown }).executionMode === "fast") {
|
||||
/*
|
||||
FNXC:WorkflowFastMode 2026-07-01-00:00:
|
||||
Raw WorkflowGraphTaskRunner tests and compatibility callers can run without the TaskExecutor custom-node service that normally owns fast-mode skips. In that fallback posture, skip executable custom prompt/script/gate nodes at the runner boundary so default-on optional review groups do not fail a fast-mode built-in workflow before legacy seams run.
|
||||
|
||||
FNXC:WorkflowFastMode 2026-07-01-00:00:
|
||||
Explicitly selected optional-group bodies carry workflow:optionalGroupActive and must still execute in fast mode because selecting the optional workflow step is operator intent, not default built-in review behavior.
|
||||
*/
|
||||
const hasExecutableConfig =
|
||||
typeof node.config?.prompt === "string" ||
|
||||
typeof node.config?.scriptName === "string" ||
|
||||
typeof node.config?.skill === "string";
|
||||
const isExplicitOptionalGroupNode = typeof c[WORKFLOW_OPTIONAL_GROUP_CONTEXT_KEY] === "string";
|
||||
if (hasExecutableConfig && !isExplicitOptionalGroupNode) {
|
||||
return Promise.resolve({ outcome: "success", value: "fast-mode-skipped" });
|
||||
}
|
||||
}
|
||||
sideEffectsRan = true;
|
||||
invoked.push(node.id);
|
||||
return this.deps.runCustomNode(node, t, c);
|
||||
@@ -269,6 +287,22 @@ export class WorkflowGraphTaskRunner {
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const fastModeFallbackParseSteps: ParseStepsHandlerDeps | undefined =
|
||||
!this.deps.primitives &&
|
||||
!this.deps.parseStepsDeps &&
|
||||
(task as { executionMode?: unknown }).executionMode === "fast"
|
||||
? {
|
||||
/*
|
||||
FNXC:WorkflowFastMode 2026-07-01-00:00:
|
||||
Raw legacy-seam graph runs do not have TaskExecutor's parse-steps dependencies. For fast-mode compatibility, parse the task prompt from memory and project the parsed steps back onto the runner task so the stepwise built-in can reach the seam-backed lifecycle suffix without a store-backed projection.
|
||||
*/
|
||||
readArtifact: async (_task, key) => (key === "PROMPT.md" ? task.prompt : undefined),
|
||||
writeSteps: async (_task, steps: TaskStep[]) => {
|
||||
task.steps = steps;
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const executor = new WorkflowGraphExecutor({
|
||||
seams: wrappedSeams,
|
||||
primitives: wrappedPrimitives,
|
||||
@@ -279,7 +313,7 @@ export class WorkflowGraphTaskRunner {
|
||||
branchSemaphore: this.deps.branchSemaphore,
|
||||
stepInstancePersistence: this.deps.stepInstancePersistence,
|
||||
onReworkReset: this.deps.onReworkReset,
|
||||
parseStepsDeps: this.deps.parseStepsDeps,
|
||||
parseStepsDeps: this.deps.parseStepsDeps ?? fastModeFallbackParseSteps,
|
||||
runCode: this.deps.runCode,
|
||||
notifyDispatch: this.deps.notifyDispatch,
|
||||
prNodes: this.deps.prNodes,
|
||||
|
||||
@@ -1,15 +1,45 @@
|
||||
import { WorkflowIrError, getStepParser, instanceNodeId } from "@fusion/core";
|
||||
import type { NotificationEvent, NotificationPayload, Settings, TaskDetail, TaskStep, WorkflowIrNode } from "@fusion/core";
|
||||
import { WorkflowIrError, instanceNodeId } from "@fusion/core";
|
||||
import type { TaskDetail, WorkflowIrNode } from "@fusion/core";
|
||||
|
||||
import type { WorkflowNodeHandler, WorkflowNodeResult } from "./workflow-graph-executor.js";
|
||||
import { createPrNodeHandlers, createAutoMergeGateHandler, type PrNodeDeps } from "./pr-nodes.js";
|
||||
import { schedulerLog } from "./logger.js";
|
||||
import {
|
||||
primitiveNodeContext,
|
||||
type WorkflowPrimitiveContext,
|
||||
type WorkflowRuntimePrimitives,
|
||||
} from "./runtime-primitives.js";
|
||||
import { runWorkflowMergeAttemptNode } from "./workflow-merge-nodes.js";
|
||||
import { createGateHandler } from "./workflow-node-runners/gate-runner.js";
|
||||
import {
|
||||
createParseStepsHandler,
|
||||
type ParseStepsHandlerDeps,
|
||||
} from "./workflow-node-runners/parse-steps-runner.js";
|
||||
import {
|
||||
createCodeNodeHandler,
|
||||
type CodeNodeRunnerDelegate as CodeNodeRunner,
|
||||
} from "./workflow-node-runners/code-runner.js";
|
||||
import {
|
||||
createNotifyHandler,
|
||||
type WorkflowNotifyDispatch,
|
||||
} from "./workflow-node-runners/notify-runner.js";
|
||||
import {
|
||||
createMergeAttemptHandler,
|
||||
createMergeGateHandler,
|
||||
} from "./workflow-node-runners/merge-runner.js";
|
||||
|
||||
export { createGateHandler } from "./workflow-node-runners/gate-runner.js";
|
||||
export {
|
||||
createParseStepsHandler,
|
||||
PARSE_STEPS_DEFAULT_ARTIFACT,
|
||||
type ParseStepsHandlerDeps,
|
||||
} from "./workflow-node-runners/parse-steps-runner.js";
|
||||
export {
|
||||
createCodeNodeHandler,
|
||||
type CodeNodeRunnerDelegate as CodeNodeRunner,
|
||||
} from "./workflow-node-runners/code-runner.js";
|
||||
export {
|
||||
createNotifyHandler,
|
||||
type WorkflowNotifyDispatch,
|
||||
} from "./workflow-node-runners/notify-runner.js";
|
||||
|
||||
// FNXC:WorkflowExecution 2026-06-25-00:00: U4 (KTD-2) — the `workflow-step` seam
|
||||
// was removed. Workflow quality gates run as the graph's own optional-group /
|
||||
@@ -386,40 +416,6 @@ export function createPrimitivePromptLikeHandler(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate handler. Two forms:
|
||||
* - Context gate (original scaffold contract): `config.expect` compared against
|
||||
* a context key — pure, no execution.
|
||||
* - Executable gate: a gate node carrying a prompt/script config runs through
|
||||
* the custom-node runner; its outcome decides whether the gate passes.
|
||||
*/
|
||||
export function createGateHandler(runCustomNode?: WorkflowCustomNodeRunner): WorkflowNodeHandler {
|
||||
return async (node, context) => {
|
||||
const expected = node.config?.expect;
|
||||
if (typeof expected === "string") {
|
||||
const actual = context.context[String(node.config?.contextKey ?? "outcome")];
|
||||
if (actual !== expected) {
|
||||
return { outcome: "failure", value: "gate-mismatch" };
|
||||
}
|
||||
return { outcome: "success" };
|
||||
}
|
||||
|
||||
const hasExecutableConfig =
|
||||
typeof node.config?.prompt === "string" || typeof node.config?.scriptName === "string";
|
||||
if (hasExecutableConfig) {
|
||||
// Fail closed: an executable gate with no runner must NOT auto-pass — that
|
||||
// would silently bypass the gate and let the workflow continue. Mirror the
|
||||
// prompt/script handler, which throws in the same situation.
|
||||
if (!runCustomNode) {
|
||||
throw new WorkflowIrError(`No custom-node runner registered for node: ${node.id}`);
|
||||
}
|
||||
return runCustomNode(node, context.task, context.context);
|
||||
}
|
||||
|
||||
return { outcome: "success" };
|
||||
};
|
||||
}
|
||||
|
||||
/** Per-step-review-node cap on UNAVAILABLE retries before routing the
|
||||
* `outcome:unavailable` edge (KTD-4 — mirrors the in-session
|
||||
* `planSpecUnavailableCounts` limiter posture, executor.ts ~7297). */
|
||||
@@ -562,304 +558,6 @@ export function createPrimitiveStepReviewHandler(primitives: WorkflowRuntimePrim
|
||||
};
|
||||
}
|
||||
|
||||
// ── parse-steps node (U12, KTD-12) ──────────────────────────────────────────
|
||||
|
||||
/** The implicit default step-source artifact when a workflow declares no
|
||||
* artifacts (mirrors core's IMPLICIT_DEFAULT_ARTIFACT). */
|
||||
export const PARSE_STEPS_DEFAULT_ARTIFACT = "PROMPT.md";
|
||||
|
||||
/**
|
||||
* Engine-side dependencies the `parse-steps` handler needs (U12, KTD-12). All
|
||||
* injected so the handler stays unit-testable with fakes and the graph layer
|
||||
* stays engine-agnostic. The production wiring (executor.ts) reads the artifact
|
||||
* through the task-documents machinery (falling back to the task's PROMPT
|
||||
* content for the default `PROMPT.md` artifact), writes the parsed step list
|
||||
* through the graph-source projection (`updateTask({ steps })`), and reports
|
||||
* whether the foreach pin is already established (KTD-3 pin protection).
|
||||
*/
|
||||
export interface ParseStepsHandlerDeps {
|
||||
/**
|
||||
* Read an artifact's text content for a task. Resolves `undefined` when the
|
||||
* artifact does not exist (the handler maps that to `parse-error`). The
|
||||
* executor wires this to the task-documents read path with a PROMPT.md
|
||||
* fallback to the task's own PROMPT content.
|
||||
*/
|
||||
readArtifact: (task: TaskDetail, key: string) => Promise<string | undefined>;
|
||||
/**
|
||||
* Write the canonical parsed step list through the projection sink (the single
|
||||
* graph-side step-list writer, KTD-12). All statuses are `pending`;
|
||||
* `dependsOn` is preserved. The executor wires this to
|
||||
* `store.updateTask(taskId, { steps })`.
|
||||
*/
|
||||
writeSteps: (task: TaskDetail, steps: TaskStep[]) => Promise<void>;
|
||||
/**
|
||||
* Pin-protection probe (KTD-3): resolves true when a foreach has already
|
||||
* expanded for this task+run — either persisted instance rows exist OR a
|
||||
* foreach expanded earlier in this walk. Re-parsing after expansion is illegal
|
||||
* (it would silently desynchronize the pinned instance set), so the handler
|
||||
* resumes without rewriting the step projection. Optional — absent means no
|
||||
* pin established (always safe to parse).
|
||||
*/
|
||||
hasExpandedForeach?: (task: TaskDetail) => Promise<boolean> | boolean;
|
||||
/** Optional audit sink: called with a stable reason code on every routable
|
||||
* parse outcome (`parse-error`, `pin-mismatch`, `pin-resume`) so the run audit
|
||||
* records it. Never throws into the handler. */
|
||||
audit?: (reason: string, detail: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for the `parse-steps` node kind (U12, KTD-12). Reads the declared
|
||||
* artifact, resolves the parser from the core registry, runs it, and writes the
|
||||
* step list through the projection — the ONLY graph-side step-list writer.
|
||||
*
|
||||
* Outcomes:
|
||||
* - unknown parser → `outcome:failure value:"parse-error"` (audited)
|
||||
* - missing artifact → `outcome:failure value:"parse-error"` (audited)
|
||||
* - parser throws → `outcome:failure value:"parse-error"` (audited, never crashes)
|
||||
* - clean empty parse → `outcome:success value:"no-steps"` (routable; defaults to success)
|
||||
* - foreach already expanded → `outcome:success value:"already-expanded"` (audited, KTD-3)
|
||||
* - steps parsed → `outcome:success` (steps written through projection)
|
||||
*/
|
||||
export function createParseStepsHandler(deps: ParseStepsHandlerDeps): WorkflowNodeHandler {
|
||||
const audit = (reason: string, detail: string): void => {
|
||||
try {
|
||||
deps.audit?.(reason, detail);
|
||||
} catch {
|
||||
// Audit must never affect the run.
|
||||
}
|
||||
};
|
||||
|
||||
return async (node, ctx) => {
|
||||
const cfg = (node.config ?? {}) as { artifact?: unknown; parser?: unknown };
|
||||
const parserId = typeof cfg.parser === "string" ? cfg.parser : "";
|
||||
const artifactKey =
|
||||
typeof cfg.artifact === "string" && cfg.artifact.trim() !== ""
|
||||
? cfg.artifact
|
||||
: PARSE_STEPS_DEFAULT_ARTIFACT;
|
||||
|
||||
// Pin protection (KTD-3): re-parsing after a foreach has expanded is illegal.
|
||||
try {
|
||||
if (deps.hasExpandedForeach && (await deps.hasExpandedForeach(ctx.task))) {
|
||||
/*
|
||||
FNXC:WorkflowResume 2026-06-29-08:02:
|
||||
Engine restart/retry re-enters the workflow from the start node, so `parse-steps` can be reached after a foreach already has persisted instance pins. That is a resume boundary, not a fatal graph error: preserve the pinned step list by not rewriting steps, then let foreach continue from its persisted instances. Probe exceptions still fail closed below because the engine cannot prove pins are valid.
|
||||
*/
|
||||
audit(
|
||||
"pin-resume",
|
||||
`parse-steps node '${node.id}' reached after a foreach already expanded for task ${ctx.task.id}; preserving pinned steps`,
|
||||
);
|
||||
return { outcome: "success", value: "already-expanded" };
|
||||
}
|
||||
} catch (err) {
|
||||
// A pin-probe failure must fail closed (never silently re-parse).
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
audit("pin-mismatch", `parse-steps node '${node.id}' pin probe failed: ${message}`);
|
||||
return { outcome: "failure", value: "pin-mismatch" };
|
||||
}
|
||||
|
||||
// Resolve the parser from the registry (built-ins + plugin parsers, KTD-12).
|
||||
const parser = getStepParser(parserId);
|
||||
if (!parser) {
|
||||
audit(
|
||||
"parse-error",
|
||||
`parse-steps node '${node.id}' references unknown parser '${parserId}'`,
|
||||
);
|
||||
return { outcome: "failure", value: "parse-error" };
|
||||
}
|
||||
|
||||
// Read the artifact content.
|
||||
let content: string | undefined;
|
||||
try {
|
||||
content = await deps.readArtifact(ctx.task, artifactKey);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
audit(
|
||||
"parse-error",
|
||||
`parse-steps node '${node.id}' artifact '${artifactKey}' read failed: ${message}`,
|
||||
);
|
||||
return { outcome: "failure", value: "parse-error" };
|
||||
}
|
||||
if (content === undefined) {
|
||||
audit(
|
||||
"parse-error",
|
||||
`parse-steps node '${node.id}' artifact '${artifactKey}' not found for task ${ctx.task.id}`,
|
||||
);
|
||||
return { outcome: "failure", value: "parse-error" };
|
||||
}
|
||||
|
||||
// Run the parser; a throw (malformed artifact) maps to parse-error.
|
||||
let parsedSteps;
|
||||
try {
|
||||
parsedSteps = parser.parse(content).steps;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
audit(
|
||||
"parse-error",
|
||||
`parse-steps node '${node.id}' parser '${parserId}' threw: ${message}`,
|
||||
);
|
||||
return { outcome: "failure", value: "parse-error" };
|
||||
}
|
||||
|
||||
// Clean empty parse → routable no-steps outcome (defaults to success).
|
||||
if (parsedSteps.length === 0) {
|
||||
// Still write the (empty) projection so a re-parse is idempotent and the
|
||||
// foreach reads a definitive zero-step list.
|
||||
try {
|
||||
await deps.writeSteps(ctx.task, []);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
audit(
|
||||
"parse-error",
|
||||
`parse-steps node '${node.id}' failed to write empty step list: ${message}`,
|
||||
);
|
||||
return { outcome: "failure", value: "parse-error" };
|
||||
}
|
||||
return { outcome: "success", value: "no-steps" };
|
||||
}
|
||||
|
||||
// Project the parsed steps onto the task step list — all pending, dependsOn
|
||||
// preserved. This is the single graph-side step-list write (KTD-12).
|
||||
const steps: TaskStep[] = parsedSteps.map((s) => {
|
||||
const step: TaskStep = { name: s.name, status: "pending" };
|
||||
/*
|
||||
FNXC:WorkflowSteps 2026-06-29-22:50:
|
||||
A parser returning `dependsOn: []` is an explicit no-dependency declaration. Preserve array presence through the projection so scheduling can distinguish it from omitted `dependsOn`, which keeps the previous-step fallback.
|
||||
*/
|
||||
if (Array.isArray(s.dependsOn)) step.dependsOn = s.dependsOn;
|
||||
return step;
|
||||
});
|
||||
try {
|
||||
await deps.writeSteps(ctx.task, steps);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
audit(
|
||||
"parse-error",
|
||||
`parse-steps node '${node.id}' failed to write ${steps.length} steps: ${message}`,
|
||||
);
|
||||
return { outcome: "failure", value: "parse-error" };
|
||||
}
|
||||
|
||||
return { outcome: "success" };
|
||||
};
|
||||
}
|
||||
|
||||
// ── code node (U14, KTD-15) ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Runs a `code` node's source against the harness contract (U14, KTD-15) and
|
||||
* returns the result mapped to graph behavior. Injected so the handler stays
|
||||
* engine-agnostic; the production wiring (executor.ts) drives the esbuild
|
||||
* compile + child-process runner in code-node-runner.ts, assembling the ctx
|
||||
* (task subset, walk context, declared artifacts, `foreach:active` instance) and
|
||||
* routing the returned `{ outcome, value, contextPatch, customFields }`.
|
||||
*/
|
||||
export type CodeNodeRunner = (
|
||||
node: WorkflowIrNode,
|
||||
task: TaskDetail,
|
||||
context: Record<string, unknown>,
|
||||
) => Promise<WorkflowNodeResult>;
|
||||
|
||||
/**
|
||||
* Handler for the `code` node kind (U14, KTD-15). Delegates to the injected
|
||||
* runner. Fail-closed: a code node with no runner wired must NOT silently
|
||||
* succeed (it would route an unverified path forward) — it fails with an audited
|
||||
* value, mirroring the step-execute/step-review unwired posture.
|
||||
*/
|
||||
export function createCodeNodeHandler(runCode?: CodeNodeRunner): WorkflowNodeHandler {
|
||||
return async (node, ctx) => {
|
||||
if (!runCode) {
|
||||
return { outcome: "failure", value: "code-node-unwired" };
|
||||
}
|
||||
return runCode(node, ctx.task, ctx.context);
|
||||
};
|
||||
}
|
||||
|
||||
export type WorkflowNotifyDispatch = (
|
||||
event: NotificationEvent,
|
||||
payload: NotificationPayload,
|
||||
) => Promise<void> | void;
|
||||
|
||||
function stringifyTemplateValue(value: unknown): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function interpolateNotifyTemplate(
|
||||
template: string,
|
||||
vars: {
|
||||
taskId: string;
|
||||
taskTitle: string;
|
||||
workflowName: string;
|
||||
context: Record<string, unknown>;
|
||||
},
|
||||
): string {
|
||||
return template.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, (_match, rawName: string) => {
|
||||
const name = rawName.trim();
|
||||
if (name === "taskId") return vars.taskId;
|
||||
if (name === "taskTitle") return vars.taskTitle;
|
||||
if (name === "workflowName") return vars.workflowName;
|
||||
if (name.startsWith("context:")) {
|
||||
return stringifyTemplateValue(vars.context[name.slice("context:".length)]);
|
||||
}
|
||||
return `{{${rawName}}}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function createNotifyHandler(notifyDispatch?: WorkflowNotifyDispatch): WorkflowNodeHandler {
|
||||
return async (node, ctx) => {
|
||||
const cfg = (node.config ?? {}) as { event?: unknown; message?: unknown; title?: unknown };
|
||||
const event = typeof cfg.event === "string" ? cfg.event.trim() : "";
|
||||
if (!event) {
|
||||
schedulerLog.log(`Workflow notify node '${node.id}' skipped because it has no event`);
|
||||
return { outcome: "success", value: "notify-skipped" };
|
||||
}
|
||||
if (!notifyDispatch) {
|
||||
schedulerLog.log(`Workflow notify node '${node.id}' skipped because notification dispatch is unwired`);
|
||||
return { outcome: "success", value: "notify-skipped" };
|
||||
}
|
||||
|
||||
const taskTitle = typeof ctx.task.title === "string" && ctx.task.title.trim() !== ""
|
||||
? ctx.task.title
|
||||
: ctx.task.id;
|
||||
const workflowName = typeof ctx.context[WORKFLOW_ID_CONTEXT_KEY] === "string"
|
||||
? ctx.context[WORKFLOW_ID_CONTEXT_KEY]
|
||||
: "unknown";
|
||||
const vars = { taskId: ctx.task.id, taskTitle, workflowName, context: ctx.context };
|
||||
const title = typeof cfg.title === "string" ? interpolateNotifyTemplate(cfg.title, vars) : taskTitle;
|
||||
const message = typeof cfg.message === "string" ? interpolateNotifyTemplate(cfg.message, vars) : "";
|
||||
const payload: NotificationPayload = {
|
||||
taskId: ctx.task.id,
|
||||
taskTitle,
|
||||
taskDescription: ctx.task.description,
|
||||
event,
|
||||
timestamp: new Date().toISOString(),
|
||||
metadata: {
|
||||
nodeId: node.id,
|
||||
workflowName,
|
||||
title,
|
||||
message,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await notifyDispatch(event, payload);
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
schedulerLog.log(`Workflow notify node '${node.id}' dispatch failed for event=${event}: ${detail}`);
|
||||
}
|
||||
|
||||
return { outcome: "success" };
|
||||
};
|
||||
}
|
||||
|
||||
export interface DefaultNodeHandlerDeps {
|
||||
/** Workflow-native runtime primitives. When present they replace legacy seams. */
|
||||
primitives?: WorkflowRuntimePrimitives;
|
||||
@@ -935,25 +633,13 @@ export function createDefaultNodeHandlers(
|
||||
"parse-steps": parseSteps,
|
||||
code: createCodeNodeHandler(deps?.runCode),
|
||||
notify: createNotifyHandler(deps?.notifyDispatch),
|
||||
"merge-gate": async (_node, ctx) => {
|
||||
const settingsAutoMerge = (ctx.settings as Partial<Settings> | undefined)?.autoMerge;
|
||||
const autoMerge = ctx.task.autoMerge !== false && settingsAutoMerge !== false;
|
||||
return {
|
||||
outcome: "success",
|
||||
value: autoMerge ? "auto-on" : "auto-off",
|
||||
};
|
||||
},
|
||||
"merge-attempt": async (_node, ctx) => {
|
||||
if (!deps?.primitives) return { outcome: "failure", value: "merge-primitives-unwired" };
|
||||
const attempt = typeof ctx.context["workflow:work-item-attempt"] === "number"
|
||||
? ctx.context["workflow:work-item-attempt"]
|
||||
: undefined;
|
||||
return runWorkflowMergeAttemptNode(
|
||||
{ primitives: deps.primitives },
|
||||
primitiveContextForNode(_node, ctx.task, ctx.context, attempt),
|
||||
ctx.task,
|
||||
);
|
||||
},
|
||||
"merge-gate": createMergeGateHandler(),
|
||||
"merge-attempt": createMergeAttemptHandler({
|
||||
primitives: deps?.primitives,
|
||||
seams,
|
||||
buildPrimitiveContext: (node, ctx, attempt) =>
|
||||
primitiveContextForNode(node, ctx.task, ctx.context, attempt),
|
||||
}),
|
||||
"manual-merge-hold": async () => ({ outcome: "failure", value: "manual-required" }),
|
||||
"retry-backoff": async () => ({ outcome: "success" }),
|
||||
"recovery-router": async (_node, ctx) => ({
|
||||
|
||||
73
packages/engine/src/workflow-node-runner.ts
Normal file
73
packages/engine/src/workflow-node-runner.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { WorkflowIrNode } from "@fusion/core";
|
||||
|
||||
import type {
|
||||
WorkflowNodeExecutionContext,
|
||||
WorkflowNodeHandler,
|
||||
WorkflowNodeResult,
|
||||
} from "./workflow-graph-executor.js";
|
||||
|
||||
export type WorkflowNodeRunnerKind = WorkflowIrNode["kind"];
|
||||
|
||||
export type WorkflowNodeRunnerContext = WorkflowNodeExecutionContext;
|
||||
|
||||
export interface WorkflowNodeRunner {
|
||||
readonly kind: WorkflowNodeRunnerKind;
|
||||
run(node: WorkflowIrNode, context: WorkflowNodeRunnerContext): Promise<WorkflowNodeResult>;
|
||||
}
|
||||
|
||||
export type WorkflowNodeRunnerMap = Partial<Record<WorkflowNodeRunnerKind, WorkflowNodeRunner>>;
|
||||
|
||||
export interface WorkflowNodeRunnerRegistryOptions {
|
||||
runners?: Iterable<WorkflowNodeRunner>;
|
||||
handlers?: Partial<Record<WorkflowNodeRunnerKind, WorkflowNodeHandler>>;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowNodeRunners 2026-07-01-00:00:
|
||||
Workflow node behavior is being extracted from monolithic executor/reviewer/triage paths into typed engine-owned runners while workflow definitions remain declarative.
|
||||
This registry is intentionally an adapter first: it preserves the existing WorkflowNodeHandler call contract and lets later units move node kinds one at a time without changing graph traversal semantics.
|
||||
*/
|
||||
export class WorkflowNodeRunnerRegistry {
|
||||
private readonly runners = new Map<WorkflowNodeRunnerKind, WorkflowNodeRunner>();
|
||||
|
||||
public constructor(options: WorkflowNodeRunnerRegistryOptions = {}) {
|
||||
if (options.handlers) {
|
||||
for (const [kind, handler] of Object.entries(options.handlers) as Array<[
|
||||
WorkflowNodeRunnerKind,
|
||||
WorkflowNodeHandler | undefined,
|
||||
]>) {
|
||||
if (handler) this.register(handlerBackedRunner(kind, handler));
|
||||
}
|
||||
}
|
||||
|
||||
for (const runner of options.runners ?? []) {
|
||||
this.register(runner);
|
||||
}
|
||||
}
|
||||
|
||||
public register(runner: WorkflowNodeRunner): void {
|
||||
this.runners.set(runner.kind, runner);
|
||||
}
|
||||
|
||||
public get(kind: WorkflowNodeRunnerKind): WorkflowNodeRunner | undefined {
|
||||
return this.runners.get(kind);
|
||||
}
|
||||
|
||||
public toHandlers(): Partial<Record<WorkflowNodeRunnerKind, WorkflowNodeHandler>> {
|
||||
const handlers: Partial<Record<WorkflowNodeRunnerKind, WorkflowNodeHandler>> = {};
|
||||
for (const [kind, runner] of this.runners) {
|
||||
handlers[kind] = (node, context) => runner.run(node, context);
|
||||
}
|
||||
return handlers;
|
||||
}
|
||||
}
|
||||
|
||||
export function handlerBackedRunner(
|
||||
kind: WorkflowNodeRunnerKind,
|
||||
handler: WorkflowNodeHandler,
|
||||
): WorkflowNodeRunner {
|
||||
return {
|
||||
kind,
|
||||
run: (node, context) => handler(node, context),
|
||||
};
|
||||
}
|
||||
32
packages/engine/src/workflow-node-runners/code-runner.ts
Normal file
32
packages/engine/src/workflow-node-runners/code-runner.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { TaskDetail, WorkflowIrNode } from "@fusion/core";
|
||||
|
||||
import type { WorkflowNodeHandler, WorkflowNodeResult } from "../workflow-graph-executor.js";
|
||||
import type { WorkflowNodeRunner, WorkflowNodeRunnerContext } from "../workflow-node-runner.js";
|
||||
|
||||
export type CodeNodeRunnerDelegate = (
|
||||
node: WorkflowIrNode,
|
||||
task: TaskDetail,
|
||||
context: Record<string, unknown>,
|
||||
) => Promise<WorkflowNodeResult>;
|
||||
|
||||
/*
|
||||
FNXC:WorkflowNodeRunners 2026-07-01-00:00:
|
||||
Code nodes now have a dedicated runner boundary. The compile/process mechanics remain injected, and an unwired code node fails closed so graph routing cannot silently approve unexecuted code.
|
||||
*/
|
||||
export class CodeWorkflowNodeRunner implements WorkflowNodeRunner {
|
||||
public readonly kind = "code" as const;
|
||||
|
||||
public constructor(private readonly runCode?: CodeNodeRunnerDelegate) {}
|
||||
|
||||
public async run(node: WorkflowIrNode, context: WorkflowNodeRunnerContext): Promise<WorkflowNodeResult> {
|
||||
if (!this.runCode) {
|
||||
return { outcome: "failure", value: "code-node-unwired" };
|
||||
}
|
||||
return this.runCode(node, context.task, context.context);
|
||||
}
|
||||
}
|
||||
|
||||
export function createCodeNodeHandler(runCode?: CodeNodeRunnerDelegate): WorkflowNodeHandler {
|
||||
const runner = new CodeWorkflowNodeRunner(runCode);
|
||||
return (node, context) => runner.run(node, context);
|
||||
}
|
||||
42
packages/engine/src/workflow-node-runners/gate-runner.ts
Normal file
42
packages/engine/src/workflow-node-runners/gate-runner.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { WorkflowIrError } from "@fusion/core";
|
||||
|
||||
import type { WorkflowCustomNodeRunner } from "../workflow-node-handlers.js";
|
||||
import type { WorkflowNodeHandler } from "../workflow-graph-executor.js";
|
||||
import type { WorkflowNodeRunner, WorkflowNodeRunnerContext } from "../workflow-node-runner.js";
|
||||
|
||||
/*
|
||||
FNXC:WorkflowNodeRunners 2026-07-01-00:00:
|
||||
Gate behavior is an engine-owned node runner. Workflow definitions remain declarative: context gates compare graph state, while executable gates delegate to the existing custom-node execution hook until that service is extracted.
|
||||
*/
|
||||
export class GateNodeRunner implements WorkflowNodeRunner {
|
||||
public readonly kind = "gate" as const;
|
||||
|
||||
public constructor(private readonly runCustomNode?: WorkflowCustomNodeRunner) {}
|
||||
|
||||
public async run(node: Parameters<WorkflowNodeHandler>[0], context: WorkflowNodeRunnerContext) {
|
||||
const expected = node.config?.expect;
|
||||
if (typeof expected === "string") {
|
||||
const actual = context.context[String(node.config?.contextKey ?? "outcome")];
|
||||
if (actual !== expected) {
|
||||
return { outcome: "failure" as const, value: "gate-mismatch" };
|
||||
}
|
||||
return { outcome: "success" as const };
|
||||
}
|
||||
|
||||
const hasExecutableConfig =
|
||||
typeof node.config?.prompt === "string" || typeof node.config?.scriptName === "string";
|
||||
if (hasExecutableConfig) {
|
||||
if (!this.runCustomNode) {
|
||||
throw new WorkflowIrError(`No custom-node runner registered for node: ${node.id}`);
|
||||
}
|
||||
return this.runCustomNode(node, context.task, context.context);
|
||||
}
|
||||
|
||||
return { outcome: "success" as const };
|
||||
}
|
||||
}
|
||||
|
||||
export function createGateHandler(runCustomNode?: WorkflowCustomNodeRunner): WorkflowNodeHandler {
|
||||
const runner = new GateNodeRunner(runCustomNode);
|
||||
return (node, context) => runner.run(node, context);
|
||||
}
|
||||
50
packages/engine/src/workflow-node-runners/merge-runner.ts
Normal file
50
packages/engine/src/workflow-node-runners/merge-runner.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { Settings } from "@fusion/core";
|
||||
|
||||
import type { WorkflowNodeHandler } from "../workflow-graph-executor.js";
|
||||
import type { WorkflowPrimitiveContext, WorkflowRuntimePrimitives } from "../runtime-primitives.js";
|
||||
import { runWorkflowMergeAttemptNode } from "../workflow-merge-nodes.js";
|
||||
import type { WorkflowLegacySeams } from "../workflow-node-handlers.js";
|
||||
|
||||
type MergeRunnerNode = Parameters<WorkflowNodeHandler>[0];
|
||||
type MergeRunnerContext = Parameters<WorkflowNodeHandler>[1];
|
||||
|
||||
export interface MergeAttemptRunnerDeps {
|
||||
primitives?: WorkflowRuntimePrimitives;
|
||||
seams: Pick<WorkflowLegacySeams, "merge">;
|
||||
buildPrimitiveContext: (
|
||||
node: MergeRunnerNode,
|
||||
context: MergeRunnerContext,
|
||||
attempt?: number,
|
||||
) => WorkflowPrimitiveContext;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowNodeRunners 2026-07-01-00:00:
|
||||
Merge-attempt behavior is isolated behind a runner factory so the graph handler map no longer owns merge primitive dispatch. Primitive-backed production runs keep using WorkflowRuntimePrimitives; legacy-seam compatibility remains explicit for runner migration tests.
|
||||
*/
|
||||
export function createMergeAttemptHandler(deps: MergeAttemptRunnerDeps): WorkflowNodeHandler {
|
||||
return async (node, ctx) => {
|
||||
if (!deps.primitives) {
|
||||
return deps.seams.merge(ctx.task, ctx.context);
|
||||
}
|
||||
const attempt = typeof ctx.context["workflow:work-item-attempt"] === "number"
|
||||
? ctx.context["workflow:work-item-attempt"]
|
||||
: undefined;
|
||||
return runWorkflowMergeAttemptNode(
|
||||
{ primitives: deps.primitives },
|
||||
deps.buildPrimitiveContext(node, ctx, attempt),
|
||||
ctx.task,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function createMergeGateHandler(): WorkflowNodeHandler {
|
||||
return async (_node, ctx) => {
|
||||
const settingsAutoMerge = (ctx.settings as Partial<Settings> | undefined)?.autoMerge;
|
||||
const autoMerge = ctx.task.autoMerge !== false && settingsAutoMerge !== false;
|
||||
return {
|
||||
outcome: "success",
|
||||
value: autoMerge ? "auto-on" : "auto-off",
|
||||
};
|
||||
};
|
||||
}
|
||||
106
packages/engine/src/workflow-node-runners/notify-runner.ts
Normal file
106
packages/engine/src/workflow-node-runners/notify-runner.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { NotificationEvent, NotificationPayload } from "@fusion/core";
|
||||
|
||||
import { schedulerLog } from "../logger.js";
|
||||
import type { WorkflowNodeHandler, WorkflowNodeResult } from "../workflow-graph-executor.js";
|
||||
import type { WorkflowNodeRunner, WorkflowNodeRunnerContext } from "../workflow-node-runner.js";
|
||||
|
||||
const WORKFLOW_ID_CONTEXT_KEY = "workflow:id";
|
||||
|
||||
export type WorkflowNotifyDispatch = (
|
||||
event: NotificationEvent,
|
||||
payload: NotificationPayload,
|
||||
) => Promise<void> | void;
|
||||
|
||||
function stringifyTemplateValue(value: unknown): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function interpolateNotifyTemplate(
|
||||
template: string,
|
||||
vars: {
|
||||
taskId: string;
|
||||
taskTitle: string;
|
||||
workflowName: string;
|
||||
context: Record<string, unknown>;
|
||||
},
|
||||
): string {
|
||||
return template.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, (_match, rawName: string) => {
|
||||
const name = rawName.trim();
|
||||
if (name === "taskId") return vars.taskId;
|
||||
if (name === "taskTitle") return vars.taskTitle;
|
||||
if (name === "workflowName") return vars.workflowName;
|
||||
if (name.startsWith("context:")) {
|
||||
return stringifyTemplateValue(vars.context[name.slice("context:".length)]);
|
||||
}
|
||||
return `{{${rawName}}}`;
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowNodeRunners 2026-07-01-00:00:
|
||||
Notify nodes are a dedicated runner because their side effect is optional operator notification, not graph traversal. Missing dispatch stays a successful skipped outcome so workflows do not fail when notification infrastructure is unwired.
|
||||
*/
|
||||
export class NotifyNodeRunner implements WorkflowNodeRunner {
|
||||
public readonly kind = "notify" as const;
|
||||
|
||||
public constructor(private readonly notifyDispatch?: WorkflowNotifyDispatch) {}
|
||||
|
||||
public async run(node: Parameters<WorkflowNodeHandler>[0], ctx: WorkflowNodeRunnerContext): Promise<WorkflowNodeResult> {
|
||||
const cfg = (node.config ?? {}) as { event?: unknown; message?: unknown; title?: unknown };
|
||||
const event = typeof cfg.event === "string" ? cfg.event.trim() : "";
|
||||
if (!event) {
|
||||
schedulerLog.log(`Workflow notify node '${node.id}' skipped because it has no event`);
|
||||
return { outcome: "success", value: "notify-skipped" };
|
||||
}
|
||||
if (!this.notifyDispatch) {
|
||||
schedulerLog.log(`Workflow notify node '${node.id}' skipped because notification dispatch is unwired`);
|
||||
return { outcome: "success", value: "notify-skipped" };
|
||||
}
|
||||
|
||||
const taskTitle = typeof ctx.task.title === "string" && ctx.task.title.trim() !== ""
|
||||
? ctx.task.title
|
||||
: ctx.task.id;
|
||||
const workflowName = typeof ctx.context[WORKFLOW_ID_CONTEXT_KEY] === "string"
|
||||
? ctx.context[WORKFLOW_ID_CONTEXT_KEY]
|
||||
: "unknown";
|
||||
const vars = { taskId: ctx.task.id, taskTitle, workflowName, context: ctx.context };
|
||||
const title = typeof cfg.title === "string" ? interpolateNotifyTemplate(cfg.title, vars) : taskTitle;
|
||||
const message = typeof cfg.message === "string" ? interpolateNotifyTemplate(cfg.message, vars) : "";
|
||||
const payload: NotificationPayload = {
|
||||
taskId: ctx.task.id,
|
||||
taskTitle,
|
||||
taskDescription: ctx.task.description,
|
||||
event,
|
||||
timestamp: new Date().toISOString(),
|
||||
metadata: {
|
||||
nodeId: node.id,
|
||||
workflowName,
|
||||
title,
|
||||
message,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await this.notifyDispatch(event, payload);
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
schedulerLog.log(`Workflow notify node '${node.id}' dispatch failed for event=${event}: ${detail}`);
|
||||
}
|
||||
|
||||
return { outcome: "success" };
|
||||
}
|
||||
}
|
||||
|
||||
export function createNotifyHandler(notifyDispatch?: WorkflowNotifyDispatch): WorkflowNodeHandler {
|
||||
const runner = new NotifyNodeRunner(notifyDispatch);
|
||||
return (node, context) => runner.run(node, context);
|
||||
}
|
||||
133
packages/engine/src/workflow-node-runners/parse-steps-runner.ts
Normal file
133
packages/engine/src/workflow-node-runners/parse-steps-runner.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { getStepParser } from "@fusion/core";
|
||||
import type { TaskDetail, TaskStep, WorkflowIrNode } from "@fusion/core";
|
||||
|
||||
import type { WorkflowNodeHandler, WorkflowNodeResult } from "../workflow-graph-executor.js";
|
||||
import type { WorkflowNodeRunner, WorkflowNodeRunnerContext } from "../workflow-node-runner.js";
|
||||
|
||||
/** The implicit default step-source artifact when a workflow declares no artifacts. */
|
||||
export const PARSE_STEPS_DEFAULT_ARTIFACT = "PROMPT.md";
|
||||
|
||||
export interface ParseStepsHandlerDeps {
|
||||
readArtifact: (task: TaskDetail, key: string) => Promise<string | undefined>;
|
||||
writeSteps: (task: TaskDetail, steps: TaskStep[]) => Promise<void>;
|
||||
hasExpandedForeach?: (task: TaskDetail) => Promise<boolean> | boolean;
|
||||
audit?: (reason: string, detail: string) => void;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowNodeRunners 2026-07-01-00:00:
|
||||
Parse-steps is a runner because it is the graph-owned authority for translating task artifacts into canonical task steps. It must preserve pin protection and fail closed on parser/artifact/projection errors so foreach instances cannot desynchronize from the task projection.
|
||||
*/
|
||||
export class ParseStepsNodeRunner implements WorkflowNodeRunner {
|
||||
public readonly kind = "parse-steps" as const;
|
||||
|
||||
public constructor(private readonly deps: ParseStepsHandlerDeps) {}
|
||||
|
||||
public async run(node: WorkflowIrNode, ctx: WorkflowNodeRunnerContext): Promise<WorkflowNodeResult> {
|
||||
const cfg = (node.config ?? {}) as { artifact?: unknown; parser?: unknown };
|
||||
const parserId = typeof cfg.parser === "string" ? cfg.parser : "";
|
||||
const artifactKey =
|
||||
typeof cfg.artifact === "string" && cfg.artifact.trim() !== ""
|
||||
? cfg.artifact
|
||||
: PARSE_STEPS_DEFAULT_ARTIFACT;
|
||||
|
||||
try {
|
||||
if (this.deps.hasExpandedForeach && (await this.deps.hasExpandedForeach(ctx.task))) {
|
||||
this.audit(
|
||||
"pin-resume",
|
||||
`parse-steps node '${node.id}' reached after a foreach already expanded for task ${ctx.task.id}; preserving pinned steps`,
|
||||
);
|
||||
return { outcome: "success", value: "already-expanded" };
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.audit("pin-mismatch", `parse-steps node '${node.id}' pin probe failed: ${message}`);
|
||||
return { outcome: "failure", value: "pin-mismatch" };
|
||||
}
|
||||
|
||||
const parser = getStepParser(parserId);
|
||||
if (!parser) {
|
||||
this.audit(
|
||||
"parse-error",
|
||||
`parse-steps node '${node.id}' references unknown parser '${parserId}'`,
|
||||
);
|
||||
return { outcome: "failure", value: "parse-error" };
|
||||
}
|
||||
|
||||
let content: string | undefined;
|
||||
try {
|
||||
content = await this.deps.readArtifact(ctx.task, artifactKey);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.audit(
|
||||
"parse-error",
|
||||
`parse-steps node '${node.id}' artifact '${artifactKey}' read failed: ${message}`,
|
||||
);
|
||||
return { outcome: "failure", value: "parse-error" };
|
||||
}
|
||||
if (content === undefined) {
|
||||
this.audit(
|
||||
"parse-error",
|
||||
`parse-steps node '${node.id}' artifact '${artifactKey}' not found for task ${ctx.task.id}`,
|
||||
);
|
||||
return { outcome: "failure", value: "parse-error" };
|
||||
}
|
||||
|
||||
let parsedSteps;
|
||||
try {
|
||||
parsedSteps = parser.parse(content).steps;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.audit(
|
||||
"parse-error",
|
||||
`parse-steps node '${node.id}' parser '${parserId}' threw: ${message}`,
|
||||
);
|
||||
return { outcome: "failure", value: "parse-error" };
|
||||
}
|
||||
|
||||
if (parsedSteps.length === 0) {
|
||||
try {
|
||||
await this.deps.writeSteps(ctx.task, []);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.audit(
|
||||
"parse-error",
|
||||
`parse-steps node '${node.id}' failed to write empty step list: ${message}`,
|
||||
);
|
||||
return { outcome: "failure", value: "parse-error" };
|
||||
}
|
||||
return { outcome: "success", value: "no-steps" };
|
||||
}
|
||||
|
||||
const steps: TaskStep[] = parsedSteps.map((s) => {
|
||||
const step: TaskStep = { name: s.name, status: "pending" };
|
||||
if (Array.isArray(s.dependsOn)) step.dependsOn = s.dependsOn;
|
||||
return step;
|
||||
});
|
||||
try {
|
||||
await this.deps.writeSteps(ctx.task, steps);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.audit(
|
||||
"parse-error",
|
||||
`parse-steps node '${node.id}' failed to write ${steps.length} steps: ${message}`,
|
||||
);
|
||||
return { outcome: "failure", value: "parse-error" };
|
||||
}
|
||||
|
||||
return { outcome: "success" };
|
||||
}
|
||||
|
||||
private audit(reason: string, detail: string): void {
|
||||
try {
|
||||
this.deps.audit?.(reason, detail);
|
||||
} catch {
|
||||
// Audit must never affect the run.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createParseStepsHandler(deps: ParseStepsHandlerDeps): WorkflowNodeHandler {
|
||||
const runner = new ParseStepsNodeRunner(deps);
|
||||
return (node, context) => runner.run(node, context);
|
||||
}
|
||||
23
packages/engine/src/workflow-planning-service.ts
Normal file
23
packages/engine/src/workflow-planning-service.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { TaskDetail } from "@fusion/core";
|
||||
|
||||
import type { PlanningSessionResult, RuntimePrimitiveResult, WorkflowPrimitiveContext } from "./runtime-primitives.js";
|
||||
|
||||
/*
|
||||
FNXC:WorkflowPlanning 2026-07-01-00:00:
|
||||
Workflow planning nodes use this service boundary instead of embedding planning-session behavior in TaskExecutor primitive construction. The current graph path preserves pre-specified tasks; later triage extraction can replace this implementation without changing node runners.
|
||||
*/
|
||||
export class WorkflowPlanningService {
|
||||
public async runPlanningSession(
|
||||
_ctx: WorkflowPrimitiveContext,
|
||||
_task: TaskDetail,
|
||||
): Promise<RuntimePrimitiveResult<PlanningSessionResult>> {
|
||||
return {
|
||||
outcome: "success",
|
||||
value: "pre-specified",
|
||||
data: {
|
||||
approved: true,
|
||||
artifactKeys: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
38
packages/engine/src/workflow-review-service.ts
Normal file
38
packages/engine/src/workflow-review-service.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { reviewStep, type ReviewResult, type ReviewType } from "./reviewer.js";
|
||||
|
||||
export interface WorkflowReviewStepInput {
|
||||
cwd: string;
|
||||
taskId: string;
|
||||
stepIndex: number;
|
||||
stepName: string;
|
||||
type: ReviewType;
|
||||
promptContent: string;
|
||||
baselineSha?: string;
|
||||
options?: Parameters<typeof reviewStep>[7];
|
||||
}
|
||||
|
||||
export type WorkflowReviewStepInvoker = (input: WorkflowReviewStepInput) => Promise<ReviewResult>;
|
||||
|
||||
/*
|
||||
FNXC:WorkflowReview 2026-07-01-00:00:
|
||||
Workflow step-review nodes call reviewer behavior through this service boundary instead of invoking reviewer.ts directly from the executor seam. The executor still owns workspace fan-out and projection writes; the service owns the single-cwd review invocation contract.
|
||||
*/
|
||||
export class WorkflowReviewService {
|
||||
public constructor(private readonly invokeReviewStep: WorkflowReviewStepInvoker = defaultReviewStepInvoker) {}
|
||||
|
||||
public async reviewStep(input: WorkflowReviewStepInput): Promise<ReviewResult> {
|
||||
return this.invokeReviewStep(input);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultReviewStepInvoker: WorkflowReviewStepInvoker = (input) =>
|
||||
reviewStep(
|
||||
input.cwd,
|
||||
input.taskId,
|
||||
input.stepIndex,
|
||||
input.stepName,
|
||||
input.type,
|
||||
input.promptContent,
|
||||
input.baselineSha,
|
||||
input.options,
|
||||
);
|
||||
27
packages/engine/src/workflow-runtime-primitive-provider.ts
Normal file
27
packages/engine/src/workflow-runtime-primitive-provider.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Settings } from "@fusion/core";
|
||||
|
||||
import type { WorkflowRuntimePrimitives } from "./runtime-primitives.js";
|
||||
|
||||
export interface WorkflowRuntimePrimitiveProvider {
|
||||
create(settings: Settings): WorkflowRuntimePrimitives;
|
||||
}
|
||||
|
||||
export type WorkflowRuntimePrimitiveFactory = (settings: Settings) => WorkflowRuntimePrimitives;
|
||||
|
||||
/*
|
||||
FNXC:WorkflowRuntimePrimitives 2026-07-01-00:00:
|
||||
Runtime primitive creation is now exposed through a provider boundary so workflow node runners can depend on explicit primitive factories instead of reaching into TaskExecutor internals. TaskExecutor remains the substrate adapter during migration while later units move individual primitive bodies behind narrower dependencies.
|
||||
*/
|
||||
export class CallbackWorkflowRuntimePrimitiveProvider implements WorkflowRuntimePrimitiveProvider {
|
||||
public constructor(private readonly factory: WorkflowRuntimePrimitiveFactory) {}
|
||||
|
||||
public create(settings: Settings): WorkflowRuntimePrimitives {
|
||||
return this.factory(settings);
|
||||
}
|
||||
}
|
||||
|
||||
export function createWorkflowRuntimePrimitiveProvider(
|
||||
factory: WorkflowRuntimePrimitiveFactory,
|
||||
): WorkflowRuntimePrimitiveProvider {
|
||||
return new CallbackWorkflowRuntimePrimitiveProvider(factory);
|
||||
}
|
||||
Reference in New Issue
Block a user