feat(FN-6880): add optional-group container IR kind + validation (U1)
Introduce the `optional-group` node kind and `WorkflowOptionalGroupConfig` (defaultOn + single-pass template subgraph), validated by mirroring the loop template rules: non-empty template, single entry/exit, no nested groups, no rework edges (single-pass guarantee), no template/top-level id collisions, boolean defaultOn. Foundation for graph-native optional steps; additive and inert until producers/consumers land in later units. Includes the plan. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,612 @@
|
||||
---
|
||||
title: "feat: Optional-group container nodes + add-ons as insertable subgraphs"
|
||||
status: active
|
||||
date: 2026-06-21
|
||||
type: feat
|
||||
plan_id: 2026-06-21-002-feat-workflow-optional-group-subgraphs
|
||||
---
|
||||
|
||||
# feat: Optional-group container nodes + add-ons as insertable subgraphs
|
||||
|
||||
## Summary
|
||||
|
||||
Today "optional steps" are **execution-inert declarations**: a workflow lists `optionalSteps:
|
||||
[{ templateId, defaultOn }]`, the create/edit UI seeds a per-task `enabledWorkflowSteps` set, and a
|
||||
single hidden `workflow-step` seam node runs every enabled step *after* the graph finishes. Nothing about
|
||||
optionality is visible in the graph, and the steps cannot be placed, ordered, or composed.
|
||||
|
||||
This plan makes optionality **graph-native**. A new `optional-group` container node — modeled on the
|
||||
existing `foreach`/`loop` container nodes — holds a `template:{ nodes, edges }` subgraph. The graph
|
||||
executor runs that subgraph **once** when the group is enabled for the task and **passes through
|
||||
(skips)** it when disabled. Enable state reuses the existing per-task `enabledWorkflowSteps` facet plus a
|
||||
workflow-level `defaultOn`, keyed by the group. Separately, every prior pre-workflow **add-on** (the seven
|
||||
`WORKFLOW_STEP_TEMPLATES`: documentation-review, qa-check, security-audit, performance-review,
|
||||
accessibility-check, browser-verification, frontend-ux-design) becomes **insertable from the editor's
|
||||
template palette as a node subgraph**, and can be inserted already wrapped in an `optional-group` so an
|
||||
author can drop in "Security Audit (optional)" in one action.
|
||||
|
||||
Finally, the plan **replaces** the declaration-based system per the confirmed scope decision: the built-in
|
||||
**coding** and **stepwise-coding** workflows migrate `browser-verification` onto an `optional-group`, and
|
||||
the now-dead `WorkflowOptionalStep` / `optionalSteps` declaration, `resolveWorkflowOptionalSteps` source,
|
||||
the `workflow-step` seam node, and its compiler seam-anchor are retired — without breaking the create/edit
|
||||
toggle surfaces, which re-point to the new source.
|
||||
|
||||
**Plan depth:** Deep. Cross-cutting across core IR + validation, the engine graph executor, per-task
|
||||
persistence/seeding, the visual node editor, the built-in workflows, and a behavior-affecting removal of
|
||||
the legacy execution path.
|
||||
|
||||
---
|
||||
|
||||
## Problem Frame
|
||||
|
||||
The declaration model has three structural limits this plan removes:
|
||||
|
||||
- **Invisible & unplaceable.** `optionalSteps` never appears in the graph
|
||||
(`packages/core/src/workflow-ir-types.ts:314-339`, marked "Execution-inert; the graph executor ignores
|
||||
this facet"). All enabled steps run in one lump at the `workflow-step` seam
|
||||
(`packages/engine/src/executor.ts` `runWorkflowSteps`, gated on `enabledWorkflowSteps`), so an author
|
||||
cannot put an optional step *between* two graph nodes, order multiple optional steps, or branch on one.
|
||||
- **Add-ons are flat, not composable.** A `WorkflowStepTemplate`
|
||||
(`packages/core/src/types.ts:868-899`) is a flat prompt/script config. The seven built-in add-ons appear
|
||||
in the editor palette today only as **single "Built-in steps"** entries
|
||||
(`WorkflowNodeEditor.tsx:1002` `stepEntries`), not as subgraphs you can compose or gate.
|
||||
- **Two ways to express "run this sometimes."** The graph already has real conditional routing
|
||||
(`shouldTraverseEdge` in `packages/engine/src/workflow-graph-executor.ts:657`, edge `condition` of
|
||||
`success`/`failure`/`outcome:<x>`) and container nodes (`foreach`/`loop`), yet optionality lives in a
|
||||
parallel, execution-inert declaration channel. Converging optionality onto the graph removes the split.
|
||||
|
||||
The graph already provides every seam this needs: container nodes compile/execute via a `template`
|
||||
subgraph (`WorkflowForeachConfig`/`WorkflowLoopConfig`, `workflow-ir-types.ts:129-165`), the executor
|
||||
dispatches them in `runNodeAndTraverse` (`workflow-graph-executor.ts:431-485`), the editor renders them as
|
||||
React Flow group nodes with `parentId` children (`workflow-flow-mapping.ts:46-82,316-403,431-550`), and the
|
||||
palette can already insert multi-node subgraphs via `insertFragment` (`WorkflowNodeEditor.tsx:1425-1430`).
|
||||
The work is to add one new container kind that branches on a per-task toggle, project the add-on catalog
|
||||
into the palette as subgraphs, and migrate the built-ins off the legacy path.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **R1 — Optional-group container kind.** Add an `optional-group` node kind to the IR carrying a
|
||||
`template:{ nodes, edges }` subgraph, mirroring `WorkflowForeachConfig`. Parse + validate it.
|
||||
- **R2 — Run-or-bypass execution.** The graph executor runs the group's template **once** when the group
|
||||
is enabled for the task and **passes through** (skips the subgraph, continues to the group's children)
|
||||
when disabled. No rework budget; a single pass.
|
||||
- **R3 — Enable state reuses the per-task facet.** Whether a group runs is driven by the existing per-task
|
||||
`enabledWorkflowSteps` set plus a workflow-level `defaultOn` on the group, keyed by the group's stable
|
||||
id. New tasks seed their enabled set from each group's `defaultOn` at creation.
|
||||
- **R4 — Author optional groups in the node editor.** An author can add an `optional-group` container,
|
||||
name it, set `defaultOn`, and place nodes inside it — reusing the foreach/loop group UX. The node type is
|
||||
registered so it renders (not as `react-flow__node-default`).
|
||||
- **R5 — Every add-on is an insertable subgraph.** All seven `WORKFLOW_STEP_TEMPLATES` add-ons are
|
||||
insertable from the editor palette as a node subgraph, and offered with an "insert as optional group"
|
||||
variant that drops the add-on wrapped in an `optional-group` (seeded `defaultOn`).
|
||||
- **R6 — Built-ins migrated, behavior preserved.** The coding and stepwise-coding built-ins express
|
||||
`browser-verification` as an `optional-group` (default OFF). A task with it enabled runs the step; a task
|
||||
with it disabled does not — proven by an execution-level (not traversal-only) test.
|
||||
- **R7 — Legacy path retired without surface breakage.** The `WorkflowOptionalStep`/`optionalSteps`
|
||||
declaration, `resolveWorkflowOptionalSteps` as the toggle source, the `workflow-step` seam node, and its
|
||||
compiler seam-anchor are removed. The create/edit toggle surfaces (inline card, New Task modal, Workflow
|
||||
tab, steps dropdown) keep working by resolving their toggle list from `optional-group` nodes instead.
|
||||
- **R8 — Validation invariants hold.** Optional-group templates are validated by walking the subgraph
|
||||
(children are not in `ir.nodes`): all template nodes reachable, no illegal (non-rework) cycles, no seam
|
||||
nodes inside a group. Graphs with no optional groups serialize byte-identically (R9 of prior art).
|
||||
- **R9 — Additive serialization.** A workflow with no optional groups round-trips through the node editor
|
||||
byte-identically; `optional-group` introduces no new top-level IR keys (it is just a node kind).
|
||||
|
||||
---
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
### Execution: container branches on the per-task toggle
|
||||
|
||||
The new dispatch slots into `runNodeAndTraverse` beside `foreach`/`loop`. Enabled → run the template
|
||||
sub-walk once (reuse the loop/foreach template-walk machinery, no rework budget); disabled → return success
|
||||
and traverse the group's children, skipping the body entirely.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Prev["upstream node"] --> OG{{"optional-group node\n(id, defaultOn, template)"}}
|
||||
OG -->|"enabled = task.enabledWorkflowSteps.includes(group.id)"| CHK{enabled?}
|
||||
CHK -->|yes| RUN["run template sub-walk ONCE\n(template.nodes/edges, single pass)"]
|
||||
CHK -->|no| SKIP["pass through\noutcome=success, value=bypassed"]
|
||||
RUN --> CHILD["traverseChildren(OG, result)"]
|
||||
SKIP --> CHILD
|
||||
CHILD --> Next["downstream node"]
|
||||
```
|
||||
|
||||
Key boundary: the **decision** (run vs skip) is read from per-task state at the trigger seam, exactly like
|
||||
the existing per-task auto-merge override — so it must be consulted wherever the run is gated, not only in
|
||||
the executor branch (see Risks R-2). The **body** is an ordinary subgraph the executor already knows how to
|
||||
walk.
|
||||
|
||||
### Authoring + add-on projection: catalog → palette → graph
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CAT["WORKFLOW_STEP_TEMPLATES\n(7 add-ons: flat prompt/script config)"]
|
||||
CAT -->|"project to subgraph entry"| PAL["editor template palette\n(Built-in steps → subgraph entries)"]
|
||||
PAL -->|"insert as node"| N["prompt/script node\n(add-on config)"]
|
||||
PAL -->|"insert as optional group"| OGW["optional-group{ template:[ add-on node ], defaultOn }"]
|
||||
N --> CANVAS["canvas IR"]
|
||||
OGW --> CANVAS
|
||||
CANVAS -->|"flowToIr / irToFlow\n(group children via parentId)"| IR["WorkflowIrV2"]
|
||||
```
|
||||
|
||||
The add-on→subgraph projection reuses the existing `insertFragment` subgraph-insertion path
|
||||
(`WorkflowNodeEditor.tsx:1425`), which already remaps ids and rewires internal edges — so "insert as
|
||||
optional group" is a wrap-then-insert, not a new insertion engine.
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- **KTD-1 — `optional-group` is a container node mirroring `WorkflowForeachConfig`, not a bypass edge.**
|
||||
Per the confirmed scope decision, optionality is encapsulated in a container (foreach/loop style) holding
|
||||
a `template:{ nodes, edges }`, rather than inline nodes plus an explicit bypass edge. This reuses the
|
||||
entire group-node toolchain (IR config, validation, React Flow `parentId` children, `insertFragment`),
|
||||
and the "skip" is the container passing through rather than a visible routed edge.
|
||||
|
||||
- **KTD-2 — Enable state reuses `enabledWorkflowSteps`, keyed by the group's stable node id.** No new
|
||||
persistence. The per-task `tasks.enabledWorkflowSteps` column (`db.ts:324`,
|
||||
`store.ts:424,2140`) holds the ids of enabled groups; `defaultOn` on the group seeds it at task creation
|
||||
via the existing materialization path. Keying on the **node id** (stable for built-ins and preserved
|
||||
across editor round-trips, like foreach/loop ids) means renaming/recreating a group resets its per-task
|
||||
state — acceptable and identical to today's `templateId` keying.
|
||||
|
||||
- **KTD-3 — Single pass, no rework budget.** Unlike `foreach` (per-step) and `loop` (bounded repeat), an
|
||||
optional-group runs its template exactly once when enabled. Reuse the loop/foreach template-walk helper
|
||||
but disable rework/iteration. Rework edges are **forbidden inside** an optional-group template (validation
|
||||
rejects them) to keep the single-pass guarantee unambiguous.
|
||||
|
||||
- **KTD-4 — Re-point the toggle resolver, don't keep two sources.** `resolveWorkflowOptionalSteps`
|
||||
(`workflow-optional-steps.ts`) currently maps `ir.optionalSteps` → display metadata for the create/edit
|
||||
UI. Replace its source with a scan of `optional-group` nodes (id, group name, `defaultOn`), preserving its
|
||||
output shape (`ResolvedWorkflowOptionalStep[]`) so the inline card, New Task modal, Workflow tab, and
|
||||
steps dropdown keep consuming it unchanged. This is what lets R7 retire the declaration without breaking
|
||||
the four toggle surfaces.
|
||||
|
||||
- **KTD-5 — Add-ons stay flat configs; the palette projects them to subgraphs at insert time.** Do **not**
|
||||
rewrite `WorkflowStepTemplate` into a nodes+edges shape. A template projects to a single `prompt`/`script`
|
||||
node (carrying its `prompt`/`scriptName`/`toolMode`/`gateMode`/`phase`/model), and the "optional" variant
|
||||
wraps that node in an `optional-group`. Keeping the catalog flat avoids migrating plugin-contributed
|
||||
templates and keeps the resolver/seeding logic simple.
|
||||
|
||||
- **KTD-6 — `workflow-step` seam removal is a compiler change, not just an IR edit.** `workflow-step` is a
|
||||
registered seam anchor in the compiler's canonical pipeline
|
||||
(`workflow-compiler.ts:45,170` `planning → execute → workflow-step → review → merge`). Retiring it
|
||||
requires removing it from `SEAM_NAMES`/`expectedSeamOrder` and updating the built-in IRs + their
|
||||
byte-identity parity oracles together, or the compiler will reject (or mis-order) the migrated graphs.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
Grouped into three phases: **A — core/engine** (the construct runs), **B — editor** (authoring + add-on
|
||||
palette), **C — migration/cleanup** (built-ins on the new model, legacy path retired).
|
||||
|
||||
### Phase A — Core construct + execution
|
||||
|
||||
### U1. `optional-group` IR type, parse, and validation
|
||||
|
||||
**Goal:** Introduce the `optional-group` node kind and its `template` config, and validate it by walking
|
||||
the subgraph (R1, R8).
|
||||
|
||||
**Requirements:** R1, R8, R9
|
||||
|
||||
**Dependencies:** none
|
||||
|
||||
**Files:**
|
||||
- `packages/core/src/workflow-ir-types.ts` (modify — add `"optional-group"` to `WorkflowIrNodeKind`; add
|
||||
`WorkflowOptionalGroupConfig { defaultOn?: boolean; template: { nodes; edges } }` mirroring
|
||||
`WorkflowForeachConfig` at `:129`)
|
||||
- `packages/core/src/workflow-ir.ts` (modify — `validateV2` calls a new `validateOptionalGroup(node, ...)`;
|
||||
extend cycle/reachability/seam walks to descend into the template subgraph)
|
||||
- `packages/core/src/__tests__/workflow-ir.test.ts` (modify/create — validation cases)
|
||||
|
||||
**Approach:**
|
||||
- Add the kind + config type. The group's stable enable key is its node `id` (KTD-2); `defaultOn` lives on
|
||||
the node `config`.
|
||||
- In validation, treat the template like a foreach template: its nodes are **not** in `ir.nodes`, so every
|
||||
reader/validator must walk the subgraph explicitly (learning: per-entity blast-radius `:37`). Validate:
|
||||
all template nodes reachable from the template's entry; endpoints reference template-local nodes; **no
|
||||
rework edges** inside (KTD-3); **no seam nodes** inside (mirror `validateParallelism`'s seam-in-branch
|
||||
rule, `workflow-ir.ts:190+`).
|
||||
- `parseWorkflowIr` descends into optional-group templates the same way it clamps foreach/loop configs.
|
||||
|
||||
**Patterns to follow:** `WorkflowForeachConfig` type + `validateV2`/foreach template validation in
|
||||
`workflow-ir.ts`; the seam-in-branch check in `validateParallelism`.
|
||||
|
||||
**Test scenarios:**
|
||||
- A v2 IR with one `optional-group` (valid template) parses and validates.
|
||||
- A template referencing an undefined template-local node throws `WorkflowIrError`.
|
||||
- A rework edge inside an optional-group template is rejected.
|
||||
- A seam node (e.g. `merge-gate`) inside an optional-group template is rejected.
|
||||
- An unreachable template node is rejected.
|
||||
- A graph with **no** optional-group serializes/parses byte-identically (R9).
|
||||
|
||||
**Verification:** `pnpm --filter @fusion/core test workflow-ir` green; no change to graphs without optional
|
||||
groups.
|
||||
|
||||
---
|
||||
|
||||
### U2. Executor: run-once-or-bypass dispatch for `optional-group`
|
||||
|
||||
**Goal:** Make the graph executor run an enabled group's template once and pass through a disabled group
|
||||
(R2), reading enable state from per-task `enabledWorkflowSteps` (R3).
|
||||
|
||||
**Requirements:** R2, R3
|
||||
|
||||
**Dependencies:** U1
|
||||
|
||||
**Files:**
|
||||
- `packages/engine/src/workflow-graph-executor.ts` (modify — add an `optional-group` branch in
|
||||
`runNodeAndTraverse` at `:389-485`, beside `foreach`/`loop`)
|
||||
- `packages/engine/src/workflow-graph-loop.ts` or a shared helper (modify/extract — reuse the
|
||||
single-template-walk without iteration/rework for the enabled path)
|
||||
- `packages/engine/src/__tests__/workflow-graph-optional-group.test.ts` (create — execution-level tests)
|
||||
|
||||
**Approach:**
|
||||
- Resolve `enabled = currentTask.enabledWorkflowSteps?.includes(node.id) ?? false`. **Read the freshest
|
||||
task state** at the seam (the executor already re-reads the task in `runWorkflowSteps`); ensure
|
||||
`enabledWorkflowSteps` is in whatever projection the gate reads (learning: per-task override slim-SELECT
|
||||
trap `:105`).
|
||||
- Enabled → walk `node.config.template` once via the extracted helper, threading the same
|
||||
`runTemplateNode`/`shouldTraverseEdge` deps the foreach/loop handlers pass; collect `visitedNodeIds`;
|
||||
set `context[node:id:outcome]`. Disabled → `return await traverseChildren(node, { outcome: "success",
|
||||
value: "optional-group-bypassed" })`.
|
||||
- Namespaced child ids: reuse the foreach instance-id scheme defensively — parse candidate-style and
|
||||
validate the template node exists, not just the container (learning `:56`).
|
||||
|
||||
**Execution note:** Start with the failing **two-task divergence** execution test (below) — it is the
|
||||
contract that guards the dead-toggle failure mode.
|
||||
|
||||
**Test scenarios:**
|
||||
- **Two-task divergence (critical):** two tasks identical except `enabledWorkflowSteps` — the one including
|
||||
the group id records the template's node execution; the sibling records none and reaches the same
|
||||
downstream node. (Shape from per-task-auto-merge learning `:106`.)
|
||||
- Enabled group runs its template exactly **once** (not per-step, not looped) even when the graph has a
|
||||
foreach elsewhere.
|
||||
- Disabled group is byte-inert: downstream context/outcome identical to a graph with the group removed
|
||||
(kill-switch inertness, learning `:55`).
|
||||
- A template node failure inside an enabled group surfaces as the group's outcome and routes the group's
|
||||
`failure`/`outcome:` edges.
|
||||
|
||||
**Verification:** new engine test green; existing graph-executor tests unaffected.
|
||||
|
||||
---
|
||||
|
||||
### U3. Per-task enable resolution from optional-group nodes + `defaultOn` seeding
|
||||
|
||||
**Goal:** Re-point the per-task toggle source from `ir.optionalSteps` to `optional-group` nodes and seed
|
||||
new tasks' enabled set from each group's `defaultOn` (R3, R7-prep).
|
||||
|
||||
**Requirements:** R3, R7
|
||||
|
||||
**Dependencies:** U1
|
||||
|
||||
**Files:**
|
||||
- `packages/core/src/workflow-optional-steps.ts` (modify — `resolveWorkflowOptionalSteps` scans
|
||||
`optional-group` nodes instead of `ir.optionalSteps`, preserving `ResolvedWorkflowOptionalStep[]` output)
|
||||
- `packages/core/src/` task-creation/materialization path that seeds `enabledWorkflowSteps` from `defaultOn`
|
||||
(modify — seed from optional-group `defaultOn`; today's `materializeDefaultWorkflowSteps`, see
|
||||
`types.ts:2668-2675`)
|
||||
- `packages/core/src/__tests__/workflow-optional-steps.test.ts` (modify — group-sourced resolution)
|
||||
|
||||
**Approach:**
|
||||
- Resolver: walk `ir` (v2) nodes, collect `optional-group` nodes → `{ id, name (from config), defaultOn }`.
|
||||
Keep output shape identical so the four UI surfaces (inline card, modal, Workflow tab, dropdown) need no
|
||||
change beyond what U7/U-editor already covers. Unknown/stale ids in `enabledWorkflowSteps` are ignored
|
||||
(defensive, as today).
|
||||
- Seeding: at task creation, the enabled set is the ids of optional-group nodes whose effective
|
||||
`defaultOn` is true — mirroring the prior `optionalStep.defaultOn ?? false` precedence.
|
||||
- Grep every reader of `enabledWorkflowSteps`/`optionalSteps` and confirm each now resolves from groups
|
||||
(learning: consult the override at every trigger seam `:100,:102`).
|
||||
|
||||
**Test scenarios:**
|
||||
- `resolveWorkflowOptionalSteps` over a workflow with two optional-group nodes returns both, with names and
|
||||
`defaultOn` from node config.
|
||||
- A new task created against that workflow seeds `enabledWorkflowSteps` to exactly the `defaultOn: true`
|
||||
group ids.
|
||||
- A workflow with no optional-group nodes resolves to `[]` and seeds an empty set.
|
||||
- Stale id in `enabledWorkflowSteps` (group since removed) does not crash resolution or execution.
|
||||
|
||||
**Verification:** core optional-steps tests green; creation seeding covered.
|
||||
|
||||
---
|
||||
|
||||
### Phase B — Editor authoring + add-on palette
|
||||
|
||||
### U4. Render & author the `optional-group` container in the node editor
|
||||
|
||||
**Goal:** Let an author add, name, configure (`defaultOn`), and fill an `optional-group` container,
|
||||
reusing the foreach/loop group UX, with the node type registered so it renders (R4).
|
||||
|
||||
**Requirements:** R4
|
||||
|
||||
**Dependencies:** U1 (kind exists)
|
||||
|
||||
**Files:**
|
||||
- `packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx` (modify — add `optional-group` to the
|
||||
node-type registry + icon, render as a group container like `foreach`/`loop`)
|
||||
- `packages/dashboard/app/components/workflow-flow-mapping.ts` (modify — treat `optional-group` as a group
|
||||
kind everywhere foreach/loop are special-cased: `groupTemplateConfigOf` `:266`, group child
|
||||
reassembly `:441-480`, intra-template edge handling `:517`, group delete `:611,:636`,
|
||||
condition-editability `:663`)
|
||||
- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify — inspector controls: group name +
|
||||
`defaultOn` toggle; help entry)
|
||||
- `packages/dashboard/app/components/nodes/node-help.ts` (modify — add an `optional-group` help entry)
|
||||
- `packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts` (modify — round-trip group
|
||||
children)
|
||||
- `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx` (modify — add/name/toggle/fill)
|
||||
|
||||
**Approach:**
|
||||
- Mirror the `foreach`/`loop` group node: a React Flow `type: "group"` with `parentId` children using the
|
||||
existing `foreachChildFlowId` namespacing (`workflow-flow-mapping.ts:75`). `irToFlow` renders the
|
||||
template as children; `flowToIr` reassembles it (the `:454,:480` kind checks gain `optional-group`).
|
||||
- Inspector: a `defaultOn` checkbox (labeled, focus ring) and the group name; the body is authored by
|
||||
dropping nodes inside, identical to foreach.
|
||||
- **Register the node type** in `WorkflowNodeTypes.tsx` — an unregistered kind renders as
|
||||
`react-flow__node-default` with missing children (learning: worktree bundle `:24`). Verify against a
|
||||
fresh `FUSION_CLIENT_DIR` bundle, non-4040 port.
|
||||
|
||||
**Test scenarios:**
|
||||
- Adding an optional-group, dropping a prompt node inside, and saving yields IR with an `optional-group`
|
||||
node whose `template.nodes` contains the inner node (round-trip).
|
||||
- Toggling `defaultOn` marks the editor dirty and persists on save.
|
||||
- Deleting the group removes its `parentId` children (no orphans) — mirrors the foreach delete test.
|
||||
- The node renders with its registered type (not `react-flow__node-default`) — asserted via node-type
|
||||
registry presence.
|
||||
|
||||
**Verification:** mapping + editor tests green; real-browser check that the container renders, accepts
|
||||
child nodes, and the `defaultOn` toggle works (fresh worktree bundle; verify on a mobile viewport per
|
||||
Risks R-4).
|
||||
|
||||
---
|
||||
|
||||
### U5. Add-ons as insertable subgraphs (plus "insert as optional group")
|
||||
|
||||
**Goal:** Make all seven `WORKFLOW_STEP_TEMPLATES` add-ons insertable from the palette as node subgraphs,
|
||||
each also offerable wrapped in an `optional-group` (R5).
|
||||
|
||||
**Requirements:** R5, KTD-5
|
||||
|
||||
**Dependencies:** U4 (optional-group authoring exists)
|
||||
|
||||
**Files:**
|
||||
- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify — the "Built-in steps" palette
|
||||
section `:1002,:2741`, the existing `stepTemplateToNode()` projector `:249-275`, and the
|
||||
`handleInsertStepTemplate`/`handleInsertFragment` handlers `:1425-1453`: add an "insert as optional
|
||||
group" variant per add-on)
|
||||
- `packages/dashboard/app/components/workflow-flow-mapping.ts` (modify — `insertFragment` `:1110-1219`
|
||||
already expands subgraphs incl. group `parentId` children; add the wrap-in-`optional-group` helper that
|
||||
builds the fragment IR from a projected add-on node)
|
||||
- `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx` (modify — insert-as-node and
|
||||
insert-as-optional-group for an add-on)
|
||||
|
||||
**Approach:**
|
||||
- Project each add-on to a node using the **existing `stepTemplateToNode()`** (`WorkflowNodeEditor.tsx:
|
||||
249-275`), which already maps a `WorkflowStepTemplate` → a `prompt`/`script` node carrying its
|
||||
`prompt`/`scriptName`/`toolMode`/`gateMode`/model (KTD-5). "Insert as node" is today's behavior.
|
||||
- "Insert as optional group" wraps that projected node in an `optional-group{ template:{ nodes:[node],
|
||||
edges:[] }, defaultOn }` (seeded from the template's `defaultOn`) and inserts it through the existing
|
||||
`insertFragment` path (`workflow-flow-mapping.ts:1110-1219`), which already remaps ids, rewires internal
|
||||
edges, and expands group `parentId` children — so no new insertion engine is needed.
|
||||
- Surface both variants in the palette's existing "Built-in steps" group; keep `data-testid` conventions.
|
||||
|
||||
**Test scenarios:**
|
||||
- Each of the seven add-ons appears in the palette and inserts a node carrying its template config.
|
||||
- "Insert as optional group" for `security-audit` yields an `optional-group` whose `template` holds the
|
||||
security-audit node and whose `defaultOn` matches the template default.
|
||||
- Inserting an add-on subgraph remaps ids so two insertions of the same add-on do not collide.
|
||||
- A plugin-contributed template (if present) still inserts as a node (catalog stays flat, KTD-5).
|
||||
|
||||
**Verification:** editor tests green; real-browser insert of an add-on and an optional-group-wrapped add-on,
|
||||
then save → reopen round-trip.
|
||||
|
||||
---
|
||||
|
||||
### Phase C — Migrate built-ins, retire the legacy path
|
||||
|
||||
### U6. Migrate coding + stepwise-coding built-ins to `optional-group` browser-verification
|
||||
|
||||
**Goal:** Express `browser-verification` as an `optional-group` (default OFF) in both built-ins, preserving
|
||||
runtime behavior, and update parity oracles (R6).
|
||||
|
||||
**Requirements:** R6, R8
|
||||
|
||||
**Dependencies:** U2, U3 (execution + seeding), U1 (kind)
|
||||
|
||||
**Files:**
|
||||
- `packages/core/src/builtin-coding-workflow-ir.ts` (modify — replace `optionalSteps:[{templateId:
|
||||
"browser-verification"}]` `:123` + the `workflow-step` seam node `:69` with a `browser-verification`
|
||||
`optional-group` on the pre-merge path)
|
||||
- `packages/core/src/builtin-stepwise-coding-workflow-ir.ts` (modify — add the `browser-verification`
|
||||
optional-group on the pre-merge path; stepwise had no `workflow-step` seam at all)
|
||||
- `packages/core/src/__tests__/` built-in IR snapshot/parity fixtures (modify — update byte-identity
|
||||
oracles deliberately)
|
||||
- `packages/engine/src/__tests__/` stepwise/coding execution parity test (modify — enabled-runs/disabled-
|
||||
skips at the built-in level)
|
||||
|
||||
**Approach:**
|
||||
- Place the optional-group on the success path where `workflow-step` sat (pre-merge, after execute/steps,
|
||||
before review), so an enabled task runs browser-verification pre-merge exactly as before.
|
||||
- The stepwise IR is a documented byte-identity parity oracle — adding the construct shifts its snapshot;
|
||||
update the fixture deliberately and confirm the group runs **once** post-foreach, not per step-instance
|
||||
(learning `:55`, prior plan R-5).
|
||||
|
||||
**Test scenarios:**
|
||||
- `resolveWorkflowOptionalSteps(BUILTIN_CODING_WORKFLOW_IR)` returns one `browser-verification` group,
|
||||
`defaultOn: false`; same for stepwise.
|
||||
- Execution: a coding task with the group enabled runs browser-verification pre-merge; disabled does not
|
||||
(two-task divergence at the built-in level).
|
||||
- Stepwise: same divergence; the group runs once after the foreach completes.
|
||||
- Both built-ins parse and pass `validateV2`.
|
||||
|
||||
**Verification:** core + engine built-in tests green; parity oracles updated and passing.
|
||||
|
||||
---
|
||||
|
||||
### U7. Retire the declaration-based optional-steps path
|
||||
|
||||
**Goal:** Remove the now-dead `WorkflowOptionalStep`/`optionalSteps` declaration, the `workflow-step` seam
|
||||
node + handler, and its compiler seam-anchor, keeping the four toggle surfaces working via U3's resolver
|
||||
(R7).
|
||||
|
||||
**Requirements:** R7
|
||||
|
||||
**Dependencies:** U3 (resolver re-pointed), U6 (built-ins migrated — nothing still declares `optionalSteps`)
|
||||
|
||||
**Files:**
|
||||
- `packages/core/src/workflow-ir-types.ts` (modify — remove `WorkflowOptionalStep` + `WorkflowIrV2.
|
||||
optionalSteps`)
|
||||
- `packages/core/src/workflow-compiler.ts` (modify — remove `workflow-step` from `SEAM_NAMES` `:45` and
|
||||
`expectedSeamOrder` `:170`)
|
||||
- `packages/engine/src/executor.ts` (modify — remove `runWorkflowSteps` + the `workflow-step` seam
|
||||
dispatch, now unreachable)
|
||||
- `packages/dashboard/app/components/workflow-flow-mapping.ts` (modify — drop `optionalSteps` threading in
|
||||
`flowToIr`/`optionalStepsOf` if present from the prior plan)
|
||||
- `packages/core/src/__tests__/`, `packages/engine/src/__tests__/` (modify — delete/replace tests asserting
|
||||
the legacy path; keep the resolver/seeding tests now backed by groups)
|
||||
|
||||
**Approach:**
|
||||
- This is a **behavior-affecting removal** — follow the codebase's Surface Enumeration discipline (see the
|
||||
section below). Enumerate every reader of `optionalSteps`/`workflow-step`/`runWorkflowSteps` and confirm
|
||||
each is migrated or removed; do not leave a mock-masked dead path (learning: branch-group dead-wiring).
|
||||
- Removing the seam anchor changes the compiler's accepted pipeline — confirm no remaining built-in or
|
||||
fragment references `workflow-step`, then drop it from both anchor lists together with the built-in edits
|
||||
from U6.
|
||||
|
||||
**Test scenarios:**
|
||||
- Grep proves zero remaining references to `optionalSteps`, `WorkflowOptionalStep`, `workflow-step` seam,
|
||||
and `runWorkflowSteps` in non-test source.
|
||||
- The compiler accepts the migrated built-ins with `workflow-step` removed from the seam order.
|
||||
- The four toggle surfaces (inline card, New Task modal, Workflow tab, steps dropdown) still render and
|
||||
submit `enabledWorkflowSteps` — now sourced from optional-group nodes (regression).
|
||||
- A pre-existing workflow JSON that still carries `optionalSteps` (legacy persisted) does not crash parse —
|
||||
the key is ignored, not fatal (back-compat decision: tolerate-and-drop). *(Confirm this stance in review;
|
||||
alternative is a one-time parse upgrade.)*
|
||||
|
||||
**Verification:** full core + engine + dashboard suites green; `pnpm lint`, `pnpm typecheck`, `pnpm build`,
|
||||
`pnpm test:gate` pass.
|
||||
|
||||
---
|
||||
|
||||
## Surface Enumeration
|
||||
|
||||
Behavior-affecting change (new execution construct + removal of the legacy path) and a UI-affordance change
|
||||
(new node kind + retired toggle source), so per AGENTS.md ("Fix the Invariant, Not the Repro", FN-5893) the
|
||||
surfaces are enumerated:
|
||||
|
||||
- **Workflow providers/graphs:** built-in **coding** and **stepwise-coding** (both migrated, U6); any
|
||||
fragment or user workflow that declared `optionalSteps` (tolerated-and-dropped, U7).
|
||||
- **Execution states:** group **enabled** (runs once), **disabled** (passes through), **stale id** in
|
||||
`enabledWorkflowSteps` (ignored), **template failure** (routes group failure edge).
|
||||
- **Editor breakpoints:** desktop and **mobile** node editor — container render, child placement,
|
||||
`defaultOn` toggle, palette insert (both variants).
|
||||
- **Per-task toggle surfaces:** inline quick-create card, New Task modal, task-detail Workflow tab, steps
|
||||
dropdown — all re-sourced from optional-group nodes (U3/U7).
|
||||
- **Validation:** subgraph walked (not just `ir.nodes`); no rework/seam inside a group; graphs without
|
||||
optional groups byte-identical.
|
||||
- **Compiler:** seam-anchor list with `workflow-step` removed; canonical pipeline still valid for migrated
|
||||
built-ins.
|
||||
|
||||
## Symptom Verification
|
||||
|
||||
- **Original symptom:** optionality is invisible and unplaceable — enabled steps run in one lump at a
|
||||
hidden seam, and add-ons cannot be composed or gated in the graph.
|
||||
- **Exact reproduction:** build/inspect the coding workflow; `browser-verification` appears only as an
|
||||
`optionalSteps` declaration, runs at the `workflow-step` seam, and is absent from the graph; add-ons
|
||||
appear in the palette only as flat single steps.
|
||||
- **Assertion it is gone:** an enabled optional-group runs its placed template once at its graph position
|
||||
and a disabled one is inert (two-task divergence execution test, U2/U6); every add-on inserts as a
|
||||
node/optional-group subgraph (U5); no `workflow-step`/`optionalSteps` path remains (U7 greps).
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
**In scope:**
|
||||
- `optional-group` IR kind + validation (U1), executor run/bypass (U2), per-task resolution + seeding (U3).
|
||||
- Editor container authoring + node-type registration (U4); add-ons as insertable subgraphs incl.
|
||||
optional-group wrapping (U5).
|
||||
- Built-in coding + stepwise migration (U6); retiring the declaration/seam/compiler-anchor path (U7).
|
||||
|
||||
**Already built (reuse, not rebuilt):**
|
||||
- Container-node toolchain: `foreach`/`loop` config, group rendering, `parentId` children,
|
||||
`insertFragment` subgraph insertion.
|
||||
- Per-task `enabledWorkflowSteps` column, create-surface toggles, the four toggle UIs, and
|
||||
`resolveWorkflowOptionalSteps`'s output shape (source re-pointed in U3).
|
||||
- Step→node projection (`workflow-steps-to-ir.ts`) reused to project add-ons (U5).
|
||||
|
||||
### Deferred to Follow-Up Work
|
||||
- **Nested/conditional groups** (an optional-group inside a split/foreach, or gated by a workflow field
|
||||
rather than the per-task toggle) — single-level, per-task-toggle only for now.
|
||||
- **Plugin-contributed add-ons as optional-group presets** beyond inserting them as flat nodes.
|
||||
- The prior plan's deferred **unified per-task workflow-facet override** abstraction (optional steps +
|
||||
auto-merge + column-agent overrides) — strong `/ce-compound` candidate once this lands.
|
||||
|
||||
**Out of scope:**
|
||||
- New persistence/migrations. Reuses `tasks.enabledWorkflowSteps`; `optional-group` is just a node kind.
|
||||
- Rewriting `WorkflowStepTemplate` into a nodes+edges shape (KTD-5 keeps it flat).
|
||||
- Changing unrelated execution (merge lifecycle, branch groups, PR nodes).
|
||||
|
||||
---
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
- **R-1 — Subgraph-walking validation/readers miss template children.** Optional-group template nodes are
|
||||
not in `ir.nodes`; any validator, reachability pass, or id parser that only sees top-level nodes will be
|
||||
wrong. *Mitigation:* walk the subgraph explicitly and parse namespaced child ids candidate-style,
|
||||
validating the template node exists, not just the container.
|
||||
(`docs/solutions/architecture-patterns/per-entity-execution-principal-override-blast-radius.md:37,:56`)
|
||||
- **R-2 — Per-task enable consulted only in the executor branch.** If skip-vs-run is read only where the
|
||||
group executes and not at every gate/trigger that decides whether to run it, the toggle silently no-ops;
|
||||
a slim SELECT omitting `enabledWorkflowSteps` reads `undefined`. *Mitigation:* grep every gate; ensure the
|
||||
column is selected; ship the two-task divergence test.
|
||||
(`docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md:100,:102,:105,:106`)
|
||||
- **R-3 — Seam-anchor / parity-oracle drift on built-in migration.** `workflow-step` is a compiler seam
|
||||
anchor and the stepwise IR is a byte-identity oracle; removing the seam and adding the construct shifts
|
||||
snapshots and can break compile-order validation. *Mitigation:* edit built-ins, seam-anchor lists, and
|
||||
parity fixtures together (KTD-6); confirm the group runs once post-foreach.
|
||||
(`docs/solutions/architecture-patterns/workflow-native-runtime-primitives.md:117`; prior plan R-5)
|
||||
- **R-4 — Editor node-type registration + mobile.** An unregistered `optional-group` renders as
|
||||
`react-flow__node-default` with missing children, looking like a source bug; per-task toggle controls have
|
||||
a real-browser-only mobile failure history. *Mitigation:* register the node type and verify against a
|
||||
fresh `FUSION_CLIENT_DIR` worktree bundle on a non-4040 port; real-browser-check the `defaultOn` toggle on
|
||||
a mobile viewport.
|
||||
(`docs/solutions/developer-experience/browser-testing-dashboard-from-worktree-safely.md:24,:44`;
|
||||
`docs/solutions/ui-bugs/mobile-auto-merge-toggle-document-scroll-blank.md:32,:53`)
|
||||
- **R-5 — Mock-masked dead wiring on removal.** Retiring the legacy path risks a green suite over a feature
|
||||
whose new path is never actually exercised (the branch-group failure class). *Mitigation:* execution-level
|
||||
(not traversal-only) tests for enabled-runs/disabled-skips at both the construct and built-in levels;
|
||||
grep-prove the old path is gone.
|
||||
(`docs/solutions/integration-issues/branch-group-single-pr-synthetic-id-dead-wiring.md`)
|
||||
|
||||
---
|
||||
|
||||
## Sources & Research
|
||||
|
||||
- **Execution seam:** `packages/engine/src/workflow-graph-executor.ts:389-485` (`runNodeAndTraverse`
|
||||
foreach/loop dispatch), `:657` (`shouldTraverseEdge`); `workflow-graph-foreach.ts`,
|
||||
`workflow-graph-loop.ts` (template sub-walk to reuse).
|
||||
- **IR + validation:** `packages/core/src/workflow-ir-types.ts:41-165` (node/edge/container types),
|
||||
`:314-339` (legacy `optionalSteps`); `packages/core/src/workflow-ir.ts:190+` (seam-in-branch),
|
||||
`:1218-1298` (`validateV2`, cycles/endpoints), `:1327` (`parseWorkflowIr`).
|
||||
- **Per-task facet:** `packages/core/src/types.ts:2421,2663-2680` (`enabledWorkflowSteps`, `workflowId`
|
||||
precedence), `db.ts:324`, `store.ts:424,2140`; `workflow-optional-steps.ts` (resolver to re-point);
|
||||
`executor.ts` `runWorkflowSteps` (seam consumer to retire).
|
||||
- **Add-on catalog:** `packages/core/src/types.ts:868-899` (`WorkflowStepTemplate`), `:902-1150`
|
||||
(seven `WORKFLOW_STEP_TEMPLATES`: documentation-review, qa-check, security-audit, performance-review,
|
||||
accessibility-check, browser-verification, frontend-ux-design); `WorkflowNodeEditor.tsx:249-275`
|
||||
(`stepTemplateToNode` add-on→node projector to reuse).
|
||||
- **Editor:** `packages/dashboard/app/components/workflow-flow-mapping.ts:46-82,266-269,316-403,431-550`
|
||||
(group children, `groupTemplateConfigOf`), `:1110-1219` (`insertFragment` subgraph insertion);
|
||||
`WorkflowNodeEditor.tsx:985-1010` (palette: fragments/steps/plugins, sourced from
|
||||
`/api/workflow-step-templates`), `:1425-1453` (insert handlers); `nodes/WorkflowNodeTypes.tsx` (node-type
|
||||
registry to extend).
|
||||
- **Compiler:** `packages/core/src/workflow-compiler.ts:45,165-196` (seam anchors + canonical order).
|
||||
- **Built-ins:** `packages/core/src/builtin-coding-workflow-ir.ts:69,123`,
|
||||
`builtin-stepwise-coding-workflow-ir.ts:132`.
|
||||
- **Prior art:** `docs/plans/2026-06-20-001-feat-workflow-optional-steps-node-editor-modal-plan.md` (the
|
||||
declaration-based system this replaces); institutional learnings cited inline under Risks.
|
||||
147
packages/core/src/__tests__/workflow-ir-optional-group.test.ts
Normal file
147
packages/core/src/__tests__/workflow-ir-optional-group.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseWorkflowIr, serializeWorkflowIr } from "../workflow-ir.js";
|
||||
import type { WorkflowIrEdge, WorkflowIrNode, WorkflowIrV2 } from "../workflow-ir-types.js";
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-11:00:
|
||||
U1 validation contract for the `optional-group` container node — the single-pass,
|
||||
toggle-gated subgraph that replaces the declaration-based optional-steps model.
|
||||
Mirrors the loop validation suite minus loop-specific exit config.
|
||||
*/
|
||||
|
||||
const columns: WorkflowIrV2["columns"] = [{ id: "work", name: "Work", traits: [] }];
|
||||
|
||||
function groupTemplate(): { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] } {
|
||||
return {
|
||||
nodes: [
|
||||
{ id: "verify", kind: "prompt", config: { prompt: "verify in browser" } },
|
||||
{ id: "report", kind: "prompt", config: { prompt: "report" } },
|
||||
],
|
||||
edges: [{ from: "verify", to: "report" }],
|
||||
};
|
||||
}
|
||||
|
||||
function groupIr(config: Record<string, unknown> = {}): WorkflowIrV2 {
|
||||
return {
|
||||
version: "v2",
|
||||
name: "optional-group-test",
|
||||
columns,
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{
|
||||
id: "browser-verification",
|
||||
kind: "optional-group",
|
||||
config: {
|
||||
name: "Browser Verification",
|
||||
defaultOn: false,
|
||||
template: groupTemplate(),
|
||||
...config,
|
||||
},
|
||||
},
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "browser-verification" },
|
||||
{ from: "browser-verification", to: "end" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("optional-group validation", () => {
|
||||
it("parses and round-trips a valid optional-group node", () => {
|
||||
const parsed = parseWorkflowIr(groupIr()) as WorkflowIrV2;
|
||||
const group = parsed.nodes.find((n) => n.id === "browser-verification");
|
||||
|
||||
expect(group?.kind).toBe("optional-group");
|
||||
expect(parseWorkflowIr(serializeWorkflowIr(parsed))).toEqual(parsed);
|
||||
});
|
||||
|
||||
it("does not require defaultOn (defaults to off via the resolver)", () => {
|
||||
expect(() => parseWorkflowIr(groupIr({ defaultOn: undefined }))).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects a non-boolean defaultOn", () => {
|
||||
expect(() => parseWorkflowIr(groupIr({ defaultOn: "yes" as unknown as boolean }))).toThrow(
|
||||
/defaultOn must be a boolean/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an empty template", () => {
|
||||
expect(() => parseWorkflowIr(groupIr({ template: { nodes: [], edges: [] } }))).toThrow(/non-empty/);
|
||||
});
|
||||
|
||||
it("rejects a missing template", () => {
|
||||
expect(() => parseWorkflowIr(groupIr({ template: undefined }))).toThrow(
|
||||
/must declare a template/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects duplicate template node ids", () => {
|
||||
const template = groupTemplate();
|
||||
template.nodes.push({ id: "verify", kind: "script" });
|
||||
expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/duplicate node ids/);
|
||||
});
|
||||
|
||||
it("rejects template edges that leave the template", () => {
|
||||
const template = groupTemplate();
|
||||
template.edges.push({ from: "report", to: "end" });
|
||||
expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/references a node outside/);
|
||||
});
|
||||
|
||||
it("rejects rework edges inside the template (single-pass guarantee)", () => {
|
||||
const template = groupTemplate();
|
||||
template.edges.push({ from: "report", to: "verify", kind: "rework" });
|
||||
expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/may not contain rework edges/);
|
||||
});
|
||||
|
||||
it("rejects nested loop/foreach/optional-group regions", () => {
|
||||
const template = groupTemplate();
|
||||
template.nodes.push({
|
||||
id: "nested",
|
||||
kind: "optional-group",
|
||||
config: { template: groupTemplate() },
|
||||
});
|
||||
template.edges.push({ from: "report", to: "nested" });
|
||||
expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(
|
||||
/nested loop\/foreach\/optional-group/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects more than one entry node", () => {
|
||||
const template: ReturnType<typeof groupTemplate> = {
|
||||
nodes: [
|
||||
{ id: "a", kind: "prompt", config: { prompt: "a" } },
|
||||
{ id: "b", kind: "prompt", config: { prompt: "b" } },
|
||||
{ id: "join", kind: "prompt", config: { prompt: "join" } },
|
||||
],
|
||||
// a and b both have no incoming edge → two entries.
|
||||
edges: [
|
||||
{ from: "a", to: "join" },
|
||||
{ from: "b", to: "join" },
|
||||
],
|
||||
};
|
||||
expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/exactly one entry node/);
|
||||
});
|
||||
|
||||
it("rejects a template node id colliding with a top-level node id", () => {
|
||||
const template = groupTemplate();
|
||||
template.nodes[0] = { id: "start", kind: "prompt", config: { prompt: "collide" } };
|
||||
template.edges = [{ from: "start", to: "report" }];
|
||||
expect(() => parseWorkflowIr(groupIr({ template }))).toThrow(/collides with a top-level node id/);
|
||||
});
|
||||
|
||||
it("leaves graphs without optional-group nodes byte-identical", () => {
|
||||
const ir: WorkflowIrV2 = {
|
||||
version: "v2",
|
||||
name: "plain",
|
||||
columns,
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [{ from: "start", to: "end" }],
|
||||
};
|
||||
const parsed = parseWorkflowIr(ir);
|
||||
expect(parseWorkflowIr(serializeWorkflowIr(parsed))).toEqual(parsed);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ export type WorkflowIrNodeKind =
|
||||
| "join"
|
||||
| "foreach"
|
||||
| "loop"
|
||||
| "optional-group"
|
||||
| "step-review"
|
||||
| "parse-steps"
|
||||
| "code"
|
||||
@@ -164,6 +165,26 @@ export interface WorkflowLoopConfig {
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-11:00:
|
||||
An `optional-group` node is a container (mirroring `foreach`/`loop`) whose `template` subgraph the executor runs ONCE when the group is enabled for the task and passes through (skips) when disabled.
|
||||
Enable state reuses the per-task `enabledWorkflowSteps` facet keyed by the group node id, seeded from `defaultOn` at task creation — this replaces the execution-inert declaration-based optional-steps model (`WorkflowOptionalStep`/`optionalSteps`).
|
||||
Single pass only: no iteration, no rework budget. Rework edges are forbidden inside the template so the single-pass guarantee is unambiguous (validated in `validateOptionalGroup`).
|
||||
*/
|
||||
/** Config for an `optional-group` container node. `defaultOn` seeds the per-task
|
||||
* enable set at creation; the `template` is the subgraph run once when enabled.
|
||||
* Unlike `foreach`/`loop`, there is no iteration or rework — a single pass. */
|
||||
export interface WorkflowOptionalGroupConfig {
|
||||
/** Workflow-author default for whether new tasks enable this group. */
|
||||
defaultOn?: boolean;
|
||||
/** Display name for the group (editor + per-task toggle surfaces). */
|
||||
name?: string;
|
||||
template: {
|
||||
nodes: WorkflowIrNode[];
|
||||
edges: WorkflowIrEdge[];
|
||||
};
|
||||
}
|
||||
|
||||
/** Step-inversion (KTD-12): a workflow-declared task document. Artifacts ride the
|
||||
* existing task-documents machinery; `step-source` artifacts feed `parse-steps`. */
|
||||
export interface WorkflowIrArtifact {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
WorkflowHoldRelease,
|
||||
WorkflowForeachConfig,
|
||||
WorkflowLoopConfig,
|
||||
WorkflowOptionalGroupConfig,
|
||||
WorkflowFieldDefinition,
|
||||
WorkflowFieldType,
|
||||
WorkflowSettingDefinition,
|
||||
@@ -602,6 +603,108 @@ function validateLoop(
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-11:00:
|
||||
Validate an `optional-group` container template, mirroring `validateLoop` minus the loop's exit/iteration config.
|
||||
The template runs once when enabled, so rework edges (and any cycles) are forbidden inside, single entry/exit is required, and nested foreach/loop groups are rejected — keeping the single-pass guarantee unambiguous.
|
||||
`defaultOn` must be boolean when present; `name` must be a string when present.
|
||||
*/
|
||||
function validateOptionalGroup(
|
||||
node: WorkflowIrNode,
|
||||
topLevelNodeIds: Set<string>,
|
||||
columnIds: Set<string>,
|
||||
): void {
|
||||
const cfg = node.config as Partial<WorkflowOptionalGroupConfig> | undefined;
|
||||
const template = cfg?.template;
|
||||
if (
|
||||
!cfg ||
|
||||
!template ||
|
||||
!Array.isArray(template.nodes) ||
|
||||
!Array.isArray(template.edges)
|
||||
) {
|
||||
throw new WorkflowIrError(
|
||||
`optional-group node '${node.id}' must declare a template with nodes and edges arrays`,
|
||||
);
|
||||
}
|
||||
if (template.nodes.length === 0) {
|
||||
throw new WorkflowIrError(`optional-group node '${node.id}' template must be non-empty`);
|
||||
}
|
||||
if (cfg.defaultOn !== undefined && typeof cfg.defaultOn !== "boolean") {
|
||||
throw new WorkflowIrError(`optional-group node '${node.id}' defaultOn must be a boolean`);
|
||||
}
|
||||
if (cfg.name !== undefined && typeof cfg.name !== "string") {
|
||||
throw new WorkflowIrError(`optional-group node '${node.id}' name must be a string`);
|
||||
}
|
||||
|
||||
const templateNodes = template.nodes;
|
||||
const templateIds = new Set(templateNodes.map((n) => n.id));
|
||||
if (templateIds.size !== templateNodes.length) {
|
||||
throw new WorkflowIrError(`optional-group node '${node.id}' template has duplicate node ids`);
|
||||
}
|
||||
for (const inner of templateNodes) {
|
||||
if (inner.kind === "loop" || inner.kind === "foreach" || inner.kind === "optional-group") {
|
||||
throw new WorkflowIrError(
|
||||
`optional-group node '${node.id}' template may not contain nested loop/foreach/optional-group ('${inner.id}')`,
|
||||
);
|
||||
}
|
||||
if (isStepExecuteNode(inner)) {
|
||||
throw new WorkflowIrError(
|
||||
`step-execute seam node '${inner.id}' is only legal inside a foreach template`,
|
||||
);
|
||||
}
|
||||
if (inner.column !== undefined && !columnIds.has(inner.column)) {
|
||||
throw new WorkflowIrError(
|
||||
`Workflow node '${inner.id}' references undefined column '${inner.column}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const edge of template.edges) {
|
||||
const fromInside = templateIds.has(edge.from);
|
||||
const toInside = templateIds.has(edge.to);
|
||||
if (!fromInside || !toInside) {
|
||||
throw new WorkflowIrError(
|
||||
`optional-group node '${node.id}' template edge '${edge.from}' -> '${edge.to}' references a node outside the template`,
|
||||
);
|
||||
}
|
||||
if (isReworkEdge(edge)) {
|
||||
throw new WorkflowIrError(`optional-group node '${node.id}' template may not contain rework edges`);
|
||||
}
|
||||
}
|
||||
|
||||
const incoming = new Map<string, number>();
|
||||
const outgoingCount = new Map<string, number>();
|
||||
for (const edge of template.edges) {
|
||||
incoming.set(edge.to, (incoming.get(edge.to) ?? 0) + 1);
|
||||
outgoingCount.set(edge.from, (outgoingCount.get(edge.from) ?? 0) + 1);
|
||||
}
|
||||
const entries = templateNodes.filter((n) => (incoming.get(n.id) ?? 0) === 0);
|
||||
const exits = templateNodes.filter((n) => (outgoingCount.get(n.id) ?? 0) === 0);
|
||||
if (entries.length !== 1) {
|
||||
throw new WorkflowIrError(
|
||||
`optional-group node '${node.id}' template must have exactly one entry node (found ${entries.length})`,
|
||||
);
|
||||
}
|
||||
if (exits.length !== 1) {
|
||||
throw new WorkflowIrError(
|
||||
`optional-group node '${node.id}' template must have exactly one exit node (found ${exits.length})`,
|
||||
);
|
||||
}
|
||||
|
||||
const templateById = new Map(templateNodes.map((n) => [n.id, n]));
|
||||
const templateOutgoing = buildOutgoing(template.edges);
|
||||
validateNoIllegalCycles(templateNodes, templateOutgoing);
|
||||
validateParallelism(templateNodes, templateOutgoing, templateById);
|
||||
validateStepReviewRouting(templateNodes, templateOutgoing, templateById, false);
|
||||
|
||||
for (const id of templateIds) {
|
||||
if (topLevelNodeIds.has(id)) {
|
||||
throw new WorkflowIrError(
|
||||
`optional-group node '${node.id}' template node id '${id}' collides with a top-level node id`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** step-execute seam nodes are legal ONLY inside a foreach template (KTD-4):
|
||||
* reject any at the top level. (Inside-split-branch rejection is handled by
|
||||
* SEAM_FORBIDDEN_IN_BRANCH within validateParallelism.) */
|
||||
@@ -1265,6 +1368,7 @@ function validateV2(ir: WorkflowIrV2): void {
|
||||
for (const node of ir.nodes) {
|
||||
if (node.kind === "foreach") validateForeach(node, topLevelIds, columnIds);
|
||||
if (node.kind === "loop") validateLoop(node, topLevelIds, columnIds);
|
||||
if (node.kind === "optional-group") validateOptionalGroup(node, topLevelIds, columnIds);
|
||||
}
|
||||
validateStepReviewRouting(ir.nodes, outgoing, nodesById, false);
|
||||
validateParseStepsNodes(ir);
|
||||
|
||||
Reference in New Issue
Block a user